Skip to main content

trustformers_debug/
computation_graph.rs

1//! Computation graph analysis tools for debugging deep learning models.
2//!
3//! This module provides comprehensive analysis tools for computation graphs,
4//! including node analysis, dependency tracking, optimization opportunities,
5//! bottleneck detection, and graph visualization capabilities.
6
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::fmt;
11use uuid::Uuid;
12
13/// Represents a computation graph for analysis
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ComputationGraph {
16    /// Unique identifier for this graph
17    pub id: Uuid,
18    /// Map of node ID to node information
19    pub nodes: HashMap<String, GraphNode>,
20    /// Adjacency list representing edges (dependencies)
21    pub edges: HashMap<String, Vec<String>>,
22    /// Root nodes (inputs to the computation)
23    pub root_nodes: HashSet<String>,
24    /// Leaf nodes (outputs of the computation)
25    pub leaf_nodes: HashSet<String>,
26    /// Metadata about the graph
27    pub metadata: GraphMetadata,
28}
29
30/// Metadata about the computation graph
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GraphMetadata {
33    /// Name of the model/graph
34    pub name: String,
35    /// Total number of nodes
36    pub node_count: usize,
37    /// Total number of edges
38    pub edge_count: usize,
39    /// Maximum depth of the graph
40    pub max_depth: usize,
41    /// Memory usage estimate in bytes
42    pub estimated_memory_usage: u64,
43    /// FLOP count estimate
44    pub estimated_flops: u64,
45    /// Timestamp when graph was created
46    pub created_at: chrono::DateTime<chrono::Utc>,
47}
48
49/// Represents a single node in the computation graph
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GraphNode {
52    /// Unique identifier for this node
53    pub id: String,
54    /// Human-readable name
55    pub name: String,
56    /// Type of operation (e.g., "MatMul", "Add", "ReLU")
57    pub operation_type: OperationType,
58    /// Input tensor shapes
59    pub input_shapes: Vec<Vec<usize>>,
60    /// Output tensor shapes
61    pub output_shapes: Vec<Vec<usize>>,
62    /// Computational complexity (FLOPs), estimated from [`Self::input_shapes`].
63    ///
64    /// `None` when the node was built without shapes: FLOPs are a function of
65    /// tensor extents, and nothing else here can supply them. Every node used
66    /// to carry a constant here instead (`1_000_000` for MatMul, `1_000` for
67    /// elementwise ops, `5_000` for normalisations) because
68    /// [`ComputationGraphAnalyzer::create_graph`] passed an empty shape slice
69    /// to the estimator, making every shape-dependent branch unreachable.
70    pub flop_count: Option<u64>,
71    /// Memory usage estimate in bytes, from [`Self::input_shapes`]; `None` for
72    /// the same reason as [`Self::flop_count`] (previously a constant 1024).
73    pub memory_usage: Option<u64>,
74    /// Execution time in microseconds (if profiled)
75    pub execution_time_us: Option<u64>,
76    /// Number of learned parameters for parameterized operations, derived
77    /// from the node's shapes; `None` for operations that have none, or when
78    /// the shapes needed to count them are absent.
79    ///
80    /// It used to return `Some(1_000_000)` for every MatMul, `Some(500_000)`
81    /// for every Conv2D and `Some(2_000_000)` for every Embedding regardless
82    /// of the model -- literal "Example: 1M parameters" values.
83    pub parameter_count: Option<u64>,
84    /// Position in topological ordering
85    pub topo_order: Option<usize>,
86    /// Depth in the graph (distance from inputs)
87    pub depth: usize,
88    /// Additional metadata
89    pub metadata: HashMap<String, String>,
90}
91
92/// Types of operations in the computation graph
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94pub enum OperationType {
95    // Arithmetic operations
96    Add,
97    Subtract,
98    Multiply,
99    Divide,
100    MatMul,
101    Dot,
102
103    // Activation functions
104    ReLU,
105    Sigmoid,
106    Tanh,
107    GELU,
108    Softmax,
109
110    // Normalization
111    LayerNorm,
112    BatchNorm,
113    RMSNorm,
114
115    // Convolution operations
116    Conv1D,
117    Conv2D,
118    Conv3D,
119    ConvTranspose,
120
121    // Pooling operations
122    MaxPool,
123    AvgPool,
124    AdaptivePool,
125
126    // Tensor operations
127    Reshape,
128    Transpose,
129    Concat,
130    Split,
131    Slice,
132    Gather,
133    Scatter,
134
135    // Reduction operations
136    Sum,
137    Mean,
138    Max,
139    Min,
140
141    // Attention operations
142    Attention,
143    MultiHeadAttention,
144    SelfAttention,
145    CrossAttention,
146
147    // Embedding operations
148    Embedding,
149    PositionalEmbedding,
150
151    // Loss functions
152    CrossEntropyLoss,
153    MSELoss,
154    L1Loss,
155
156    // Control flow
157    If,
158    While,
159    Loop,
160
161    // Custom operations
162    Custom(String),
163}
164
165/// Configuration for computation graph analysis
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct GraphAnalysisConfig {
168    /// Whether to perform memory analysis
169    pub enable_memory_analysis: bool,
170    /// Whether to perform FLOP analysis
171    pub enable_flop_analysis: bool,
172    /// Whether to detect optimization opportunities
173    pub enable_optimization_analysis: bool,
174    /// Whether to perform bottleneck detection
175    pub enable_bottleneck_detection: bool,
176    /// Whether to analyze data flow patterns
177    pub enable_dataflow_analysis: bool,
178    /// Threshold for considering a node a bottleneck (microseconds)
179    pub bottleneck_threshold_us: u64,
180    /// Memory threshold for large operations (bytes)
181    pub large_memory_threshold: u64,
182}
183
184impl Default for GraphAnalysisConfig {
185    fn default() -> Self {
186        Self {
187            enable_memory_analysis: true,
188            enable_flop_analysis: true,
189            enable_optimization_analysis: true,
190            enable_bottleneck_detection: true,
191            enable_dataflow_analysis: true,
192            bottleneck_threshold_us: 1000,             // 1ms
193            large_memory_threshold: 1024 * 1024 * 100, // 100MB
194        }
195    }
196}
197
198/// Main computation graph analyzer
199#[derive(Debug)]
200pub struct ComputationGraphAnalyzer {
201    config: GraphAnalysisConfig,
202    graphs: HashMap<Uuid, ComputationGraph>,
203    analysis_results: HashMap<Uuid, GraphAnalysisResult>,
204}
205
206/// Comprehensive analysis result for a computation graph
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct GraphAnalysisResult {
209    /// Graph being analyzed
210    pub graph_id: Uuid,
211    /// Memory analysis results
212    pub memory_analysis: Option<MemoryAnalysis>,
213    /// FLOP analysis results
214    pub flop_analysis: Option<FlopAnalysis>,
215    /// Optimization opportunities
216    pub optimization_opportunities: Vec<OptimizationOpportunity>,
217    /// Bottleneck analysis
218    pub bottleneck_analysis: Option<BottleneckAnalysis>,
219    /// Data flow analysis
220    pub dataflow_analysis: Option<DataFlowAnalysis>,
221    /// Critical path analysis
222    pub critical_path: Vec<String>,
223    /// Graph statistics
224    pub statistics: GraphStatistics,
225    /// Recommendations for improvement
226    pub recommendations: Vec<String>,
227}
228
229/// One operation to build a [`GraphNode`] from, including the tensor shapes
230/// that make its FLOP/memory/parameter estimates computable.
231///
232/// [`ComputationGraphAnalyzer::create_graph`]'s tuple form leaves the shape
233/// fields empty, which is why the estimates it produces are `None`.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct OperationSpec {
236    /// Identifier, also used as the node's display name.
237    pub node_id: String,
238    /// What the node computes.
239    pub operation_type: OperationType,
240    /// Ids of the nodes this one consumes.
241    pub dependencies: Vec<String>,
242    /// Shapes of the operation's inputs. For a `MatMul` the second entry is
243    /// the weight matrix (see
244    /// `ComputationGraphAnalyzer::estimate_parameters`).
245    pub input_shapes: Vec<Vec<usize>>,
246    /// Shapes of the operation's outputs.
247    pub output_shapes: Vec<Vec<usize>>,
248}
249
250/// Memory usage analysis
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct MemoryAnalysis {
253    /// Total memory usage in bytes
254    pub total_memory_usage: u64,
255    /// Peak simultaneously-live memory usage in bytes, computed by a real
256    /// liveness walk over the graph's topological order (see
257    /// `ComputationGraphAnalyzer::compute_peak_memory_usage`): a node's
258    /// output is "live" from the step it is produced until the step of its
259    /// last consumer, and this is the maximum total live bytes at any one
260    /// step. Never equal to `total_memory_usage` by construction (as the
261    /// old placeholder was) unless every tensor really is live
262    /// simultaneously.
263    pub peak_memory_usage: u64,
264    /// Memory usage by operation type
265    pub memory_by_operation: HashMap<OperationType, u64>,
266    /// Nodes with highest memory usage
267    pub memory_hotspots: Vec<(String, u64)>,
268    /// Memory fragmentation ratio, when measurable. This analyzer tracks
269    /// only logical per-node byte counts, not a real memory
270    /// allocator/placement model (address ranges, allocation order,
271    /// free-list state) -- fragmentation is a property of *how* an
272    /// allocator places live tensors in physical memory, which is a
273    /// different question from liveness overlap (already captured by
274    /// [`Self::peak_memory_usage`]) and depends on a placement policy this
275    /// crate does not implement. Honestly `None` rather than a fabricated
276    /// number.
277    pub fragmentation_ratio: Option<f64>,
278    /// Suggested memory optimizations
279    pub optimization_suggestions: Vec<String>,
280}
281
282/// FLOP (Floating Point Operations) analysis
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct FlopAnalysis {
285    /// Total FLOP count
286    pub total_flops: u64,
287    /// FLOP count by operation type
288    pub flops_by_operation: HashMap<OperationType, u64>,
289    /// Nodes with highest FLOP count
290    pub compute_hotspots: Vec<(String, u64)>,
291    /// Arithmetic intensity (FLOPs per byte)
292    pub arithmetic_intensity: f64,
293    /// Computational complexity analysis
294    pub complexity_analysis: ComplexityAnalysis,
295}
296
297/// Complexity analysis of the computation
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct ComplexityAnalysis {
300    /// Asymptotic (Big-O) time complexity, when it can honestly be
301    /// derived. A single [`ComputationGraph`] is one concrete, fixed-shape
302    /// instance -- it has no symbolic size parameter `n` to be asymptotic
303    /// *in*, so there is nothing to honestly derive here today; always
304    /// `None`, never a fabricated `"O(n)"`. Concrete costs for the actual
305    /// instance are available for real via [`FlopAnalysis::total_flops`].
306    pub time_complexity: Option<String>,
307    /// Asymptotic (Big-O) space complexity. Same honesty caveat as
308    /// [`Self::time_complexity`]; concrete space for this instance is
309    /// available for real via [`MemoryAnalysis::total_memory_usage`].
310    pub space_complexity: Option<String>,
311    /// Real, structural parallelization-potential estimate in `[0, 1]`:
312    /// `1 - (critical_path_length_in_nodes / node_count)`, i.e. the
313    /// fraction of nodes that are *not* on the graph's longest
314    /// dependency chain and could in principle execute alongside it. `0.0`
315    /// for a pure sequential chain (every node is on the critical path),
316    /// approaching `1.0` for a wide, shallow graph. Computed from the
317    /// graph's real topology (see
318    /// `ComputationGraphAnalyzer::analyze_flop_usage`) -- never the old
319    /// constant `0.7`.
320    pub parallelization_potential: f64,
321    /// Sequential dependencies
322    pub sequential_dependencies: usize,
323}
324
325/// Optimization opportunity detection
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct OptimizationOpportunity {
328    /// Type of optimization
329    pub optimization_type: OptimizationType,
330    /// Description of the opportunity
331    pub description: String,
332    /// Nodes involved in this optimization
333    pub affected_nodes: Vec<String>,
334    /// Estimated performance improvement
335    pub estimated_improvement: EstimatedImprovement,
336    /// Implementation difficulty (1-5)
337    pub implementation_difficulty: u8,
338    /// Priority level
339    pub priority: OptimizationPriority,
340}
341
342/// Types of optimizations that can be applied
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub enum OptimizationType {
345    /// Fuse multiple operations into one
346    OperationFusion,
347    /// Eliminate redundant computations
348    RedundancyElimination,
349    /// Optimize memory layout
350    MemoryLayoutOptimization,
351    /// Use more efficient algorithms
352    AlgorithmicOptimization,
353    /// Parallelize sequential operations
354    Parallelization,
355    /// Optimize data access patterns
356    DataAccessOptimization,
357    /// Reduce precision where safe
358    PrecisionOptimization,
359    /// Cache intermediate results
360    Memoization,
361    /// Optimize control flow
362    ControlFlowOptimization,
363}
364
365/// Priority levels for optimizations
366#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
367pub enum OptimizationPriority {
368    Low,
369    Medium,
370    High,
371    Critical,
372}
373
374/// Estimated improvement from an optimization
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct EstimatedImprovement {
377    /// Estimated speedup (multiplicative factor)
378    pub speedup_factor: f64,
379    /// Estimated memory reduction in bytes
380    pub memory_reduction: u64,
381    /// Estimated energy savings (0.0 to 1.0)
382    pub energy_savings: f64,
383}
384
385/// Bottleneck analysis results
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct BottleneckAnalysis {
388    /// Nodes that are bottlenecks
389    pub bottleneck_nodes: Vec<String>,
390    /// Critical path through the graph
391    pub critical_path_nodes: Vec<String>,
392    /// Total critical path time
393    pub critical_path_time_us: u64,
394    /// Nodes that could benefit from parallelization
395    pub parallelizable_nodes: Vec<String>,
396    /// Scheduling suggestions
397    pub scheduling_suggestions: Vec<String>,
398}
399
400/// Data flow analysis results
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct DataFlowAnalysis {
403    /// Data dependencies between nodes
404    pub data_dependencies: HashMap<String, Vec<String>>,
405    /// Live variables at each node
406    pub live_variables: HashMap<String, HashSet<String>>,
407    /// Variable lifetime analysis
408    pub variable_lifetimes: HashMap<String, VariableLifetime>,
409    /// Memory reuse opportunities
410    pub memory_reuse_opportunities: Vec<MemoryReuseOpportunity>,
411}
412
413/// Lifetime information for a variable
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct VariableLifetime {
416    /// Node where variable is created
417    pub birth_node: String,
418    /// Node where variable is last used
419    pub death_node: String,
420    /// All nodes that use this variable
421    pub usage_nodes: Vec<String>,
422    /// Memory footprint in bytes
423    pub memory_footprint: u64,
424}
425
426/// Memory reuse opportunity
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct MemoryReuseOpportunity {
429    /// Variables that can share memory
430    pub reusable_variables: Vec<String>,
431    /// Memory that can be saved
432    pub memory_savings: u64,
433    /// Implementation complexity
434    pub complexity: u8,
435}
436
437/// Graph statistics
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct GraphStatistics {
440    /// Number of nodes by operation type
441    pub nodes_by_type: HashMap<OperationType, usize>,
442    /// Average node fan-in
443    pub average_fan_in: f64,
444    /// Average node fan-out
445    pub average_fan_out: f64,
446    /// Graph diameter (longest shortest path)
447    pub diameter: usize,
448    /// Clustering coefficient
449    pub clustering_coefficient: f64,
450    /// Number of strongly connected components
451    pub strongly_connected_components: usize,
452}
453
454impl ComputationGraphAnalyzer {
455    /// Create a new computation graph analyzer
456    pub fn new(config: GraphAnalysisConfig) -> Self {
457        Self {
458            config,
459            graphs: HashMap::new(),
460            analysis_results: HashMap::new(),
461        }
462    }
463
464    /// Add a computation graph for analysis
465    pub fn add_graph(&mut self, graph: ComputationGraph) -> Result<()> {
466        let graph_id = graph.id;
467        self.graphs.insert(graph_id, graph);
468        Ok(())
469    }
470
471    /// Create a computation graph from operations whose tensor shapes are not
472    /// known.
473    ///
474    /// Every node's `flop_count`, `memory_usage` and `parameter_count` will be
475    /// `None`: those are functions of tensor extents, and this entry point has
476    /// none to give. Use [`Self::create_graph_with_shapes`] to get real
477    /// estimates.
478    pub fn create_graph(
479        &mut self,
480        name: String,
481        operations: Vec<(String, OperationType, Vec<String>)>, // (node_id, op_type, dependencies)
482    ) -> Result<Uuid> {
483        self.create_graph_with_shapes(
484            name,
485            operations
486                .into_iter()
487                .map(|(node_id, operation_type, dependencies)| OperationSpec {
488                    node_id,
489                    operation_type,
490                    dependencies,
491                    input_shapes: Vec::new(),
492                    output_shapes: Vec::new(),
493                })
494                .collect(),
495        )
496    }
497
498    /// Create a computation graph from operations that carry their real tensor
499    /// shapes, so the per-node FLOP, memory and parameter estimates are
500    /// actually computed instead of falling back to constants.
501    pub fn create_graph_with_shapes(
502        &mut self,
503        name: String,
504        operations: Vec<OperationSpec>,
505    ) -> Result<Uuid> {
506        let graph_id = Uuid::new_v4();
507        let mut nodes = HashMap::new();
508        let mut edges = HashMap::new();
509        let mut root_nodes = HashSet::new();
510        let mut leaf_nodes = HashSet::new();
511
512        // Create nodes
513        for spec in &operations {
514            let OperationSpec {
515                node_id,
516                operation_type: op_type,
517                dependencies,
518                input_shapes,
519                output_shapes,
520            } = spec;
521            let node = GraphNode {
522                id: node_id.clone(),
523                name: node_id.clone(),
524                operation_type: op_type.clone(),
525                input_shapes: input_shapes.clone(),
526                output_shapes: output_shapes.clone(),
527                flop_count: self.estimate_flops(op_type, input_shapes),
528                memory_usage: self.estimate_memory(op_type, input_shapes),
529                execution_time_us: None,
530                parameter_count: self.estimate_parameters(op_type, input_shapes),
531                topo_order: None,
532                depth: 0,
533                metadata: HashMap::new(),
534            };
535            nodes.insert(node_id.clone(), node);
536
537            // Track dependencies
538            if dependencies.is_empty() {
539                root_nodes.insert(node_id.clone());
540            }
541            edges.insert(node_id.clone(), dependencies.clone());
542        }
543
544        // Identify leaf nodes
545        let all_dependencies: HashSet<String> = edges.values().flatten().cloned().collect();
546        for node_id in nodes.keys() {
547            if !all_dependencies.contains(node_id) {
548                leaf_nodes.insert(node_id.clone());
549            }
550        }
551
552        // Calculate depth and topological order
553        self.calculate_depth_and_topo_order(&mut nodes, &edges)?;
554
555        let metadata = GraphMetadata {
556            name,
557            node_count: nodes.len(),
558            edge_count: edges.values().map(|deps| deps.len()).sum(),
559            max_depth: nodes.values().map(|n| n.depth).max().unwrap_or(0),
560            estimated_memory_usage: nodes.values().filter_map(|n| n.memory_usage).sum(),
561            estimated_flops: nodes.values().filter_map(|n| n.flop_count).sum(),
562            created_at: chrono::Utc::now(),
563        };
564
565        let graph = ComputationGraph {
566            id: graph_id,
567            nodes,
568            edges,
569            root_nodes,
570            leaf_nodes,
571            metadata,
572        };
573
574        self.graphs.insert(graph_id, graph);
575        Ok(graph_id)
576    }
577
578    /// Analyze a computation graph
579    pub fn analyze_graph(&mut self, graph_id: Uuid) -> Result<GraphAnalysisResult> {
580        let graph = self
581            .graphs
582            .get(&graph_id)
583            .ok_or_else(|| anyhow::anyhow!("Graph not found: {}", graph_id))?;
584
585        let mut result = GraphAnalysisResult {
586            graph_id,
587            memory_analysis: None,
588            flop_analysis: None,
589            optimization_opportunities: Vec::new(),
590            bottleneck_analysis: None,
591            dataflow_analysis: None,
592            critical_path: Vec::new(),
593            statistics: self.calculate_statistics(graph)?,
594            recommendations: Vec::new(),
595        };
596
597        // Perform different types of analysis based on configuration
598        if self.config.enable_memory_analysis {
599            result.memory_analysis = Some(self.analyze_memory_usage(graph)?);
600        }
601
602        if self.config.enable_flop_analysis {
603            result.flop_analysis = Some(self.analyze_flop_usage(graph)?);
604        }
605
606        if self.config.enable_optimization_analysis {
607            result.optimization_opportunities = self.detect_optimization_opportunities(graph)?;
608        }
609
610        if self.config.enable_bottleneck_detection {
611            result.bottleneck_analysis = Some(self.analyze_bottlenecks(graph)?);
612        }
613
614        if self.config.enable_dataflow_analysis {
615            result.dataflow_analysis = Some(self.analyze_dataflow(graph)?);
616        }
617
618        result.critical_path = self.find_critical_path(graph)?;
619        result.recommendations = self.generate_recommendations(&result)?;
620
621        self.analysis_results.insert(graph_id, result.clone());
622        Ok(result)
623    }
624
625    /// Get analysis results for a graph
626    pub fn get_analysis_result(&self, graph_id: Uuid) -> Option<&GraphAnalysisResult> {
627        self.analysis_results.get(&graph_id)
628    }
629
630    /// Export graph analysis to DOT format for visualization
631    pub fn export_to_dot(&self, graph_id: Uuid) -> Result<String> {
632        let graph = self
633            .graphs
634            .get(&graph_id)
635            .ok_or_else(|| anyhow::anyhow!("Graph not found: {}", graph_id))?;
636
637        let mut dot = String::new();
638        dot.push_str(&format!("digraph \"{}\" {{\n", graph.metadata.name));
639        dot.push_str("  rankdir=TB;\n");
640        dot.push_str("  node [shape=box, style=filled];\n\n");
641
642        // Add nodes with styling based on operation type
643        for node in graph.nodes.values() {
644            let color = self.get_node_color(&node.operation_type);
645            let label = format!(
646                "{}\\n{}\\n{}\\n{}",
647                node.name,
648                format!("{:?}", node.operation_type),
649                node.flop_count.map_or_else(
650                    || "FLOPs n/a".to_string(),
651                    |f| format!("{:.1} GFLOP", f as f64 / 1e9)
652                ),
653                node.memory_usage.map_or_else(
654                    || "memory n/a".to_string(),
655                    |m| format!("{:.1} MB", m as f64 / (1024.0 * 1024.0))
656                )
657            );
658
659            dot.push_str(&format!(
660                "  \"{}\" [label=\"{}\", fillcolor=\"{}\"];\n",
661                node.id, label, color
662            ));
663        }
664
665        dot.push('\n');
666
667        // Add edges
668        for (node_id, dependencies) in &graph.edges {
669            for dep in dependencies {
670                dot.push_str(&format!("  \"{}\" -> \"{}\";\n", dep, node_id));
671            }
672        }
673
674        dot.push_str("}\n");
675        Ok(dot)
676    }
677
678    // Private helper methods
679
680    fn calculate_depth_and_topo_order(
681        &self,
682        nodes: &mut HashMap<String, GraphNode>,
683        edges: &HashMap<String, Vec<String>>,
684    ) -> Result<()> {
685        // Topological sort and depth calculation
686        let mut in_degree: HashMap<String, usize> = HashMap::new();
687        let mut adj_list: HashMap<String, Vec<String>> = HashMap::new();
688
689        // Initialize in-degrees and adjacency list
690        for node_id in nodes.keys() {
691            in_degree.insert(node_id.clone(), 0);
692            adj_list.insert(node_id.clone(), Vec::new());
693        }
694
695        for (node_id, dependencies) in edges {
696            in_degree.insert(node_id.clone(), dependencies.len());
697            for dep in dependencies {
698                if let Some(adj) = adj_list.get_mut(dep) {
699                    adj.push(node_id.clone());
700                }
701            }
702        }
703
704        // Kahn's algorithm for topological sorting and depth calculation
705        let mut queue = VecDeque::new();
706        let mut topo_order = 0;
707
708        // Find all nodes with no incoming edges
709        for (node_id, &degree) in &in_degree {
710            if degree == 0 {
711                queue.push_back((node_id.clone(), 0)); // (node_id, depth)
712            }
713        }
714
715        while let Some((node_id, depth)) = queue.pop_front() {
716            // Update node
717            if let Some(node) = nodes.get_mut(&node_id) {
718                node.depth = depth;
719                node.topo_order = Some(topo_order);
720                topo_order += 1;
721            }
722
723            // Process neighbors
724            if let Some(neighbors) = adj_list.get(&node_id) {
725                for neighbor in neighbors {
726                    if let Some(degree) = in_degree.get_mut(neighbor) {
727                        *degree -= 1;
728                        if *degree == 0 {
729                            queue.push_back((neighbor.clone(), depth + 1));
730                        }
731                    }
732                }
733            }
734        }
735
736        Ok(())
737    }
738
739    /// FLOPs for one execution of `op_type` over `shapes`, or `None` when the
740    /// shapes needed for the count are missing.
741    ///
742    /// The constant fallbacks this replaces (1_000_000 / 1_000 / 5_000) were
743    /// unconditionally reachable, because the only in-crate caller passed an
744    /// empty `shapes` slice.
745    fn estimate_flops(&self, op_type: &OperationType, shapes: &[Vec<usize>]) -> Option<u64> {
746        let elements = |s: &Vec<usize>| s.iter().product::<usize>() as u64;
747        match op_type {
748            OperationType::MatMul => {
749                let (a_shape, b_shape) = (shapes.first()?, shapes.get(1)?);
750                if a_shape.len() < 2 || b_shape.len() < 2 {
751                    return None;
752                }
753                let m = a_shape[a_shape.len() - 2];
754                let k = a_shape[a_shape.len() - 1];
755                let n = b_shape[b_shape.len() - 1];
756                Some((2 * m * k * n) as u64)
757            },
758            OperationType::Add
759            | OperationType::Subtract
760            | OperationType::Multiply
761            | OperationType::ReLU
762            | OperationType::Sigmoid
763            | OperationType::Tanh => shapes.first().map(elements),
764            // Normalisation touches each element a small constant number of
765            // times (mean, centring, variance, scale, shift).
766            OperationType::LayerNorm | OperationType::BatchNorm => {
767                shapes.first().map(|s| elements(s) * 5)
768            },
769            // Every other operation's cost model would be a guess: reported as
770            // absent rather than as the old flat 1_000.
771            _ => None,
772        }
773    }
774
775    /// Bytes touched by one execution of `op_type` over `shapes`, assuming
776    /// float32 elements; `None` when the shapes are missing.
777    fn estimate_memory(&self, op_type: &OperationType, shapes: &[Vec<usize>]) -> Option<u64> {
778        const ELEMENT_SIZE: u64 = 4;
779        if shapes.is_empty() {
780            return None;
781        }
782        match op_type {
783            OperationType::MatMul => Some(
784                shapes
785                    .iter()
786                    .map(|s| s.iter().product::<usize>() as u64 * ELEMENT_SIZE)
787                    .sum::<u64>(),
788            ),
789            _ => shapes.first().map(|s| s.iter().product::<usize>() as u64 * ELEMENT_SIZE),
790        }
791    }
792
793    /// Learned-parameter count for `op_type`, derived from `shapes`.
794    ///
795    /// For a `MatMul` the second operand *is* the weight matrix, so its element
796    /// count is the parameter count; a `LayerNorm` learns one scale and one
797    /// shift per normalised element. Anything whose parameter count cannot be
798    /// derived from the shapes present is `None` -- the previous version
799    /// returned the literals 1M / 500K / 2M / 1K keyed only on the operation
800    /// type, identical for every model and every layer size.
801    fn estimate_parameters(&self, op_type: &OperationType, shapes: &[Vec<usize>]) -> Option<u64> {
802        match op_type {
803            OperationType::MatMul => {
804                let weights = shapes.get(1)?;
805                Some(weights.iter().product::<usize>() as u64)
806            },
807            OperationType::LayerNorm => {
808                let normalised = shapes.first()?;
809                Some(2 * (*normalised.last()?) as u64)
810            },
811            _ => None,
812        }
813    }
814
815    fn analyze_memory_usage(&self, graph: &ComputationGraph) -> Result<MemoryAnalysis> {
816        // Nodes with no shape information contribute nothing rather than a
817        // constant: an unmeasured node is not a zero-byte node, but it is also
818        // not the 1024-byte one the old estimator invented for it.
819        let total_memory_usage = graph.nodes.values().filter_map(|n| n.memory_usage).sum();
820
821        let mut memory_by_operation: HashMap<OperationType, u64> = HashMap::new();
822        for node in graph.nodes.values() {
823            if let Some(memory) = node.memory_usage {
824                *memory_by_operation.entry(node.operation_type.clone()).or_insert(0) += memory;
825            }
826        }
827
828        let mut memory_hotspots: Vec<(String, u64)> = graph
829            .nodes
830            .values()
831            .filter_map(|n| n.memory_usage.map(|m| (n.id.clone(), m)))
832            .collect();
833        memory_hotspots.sort_by_key(|item| std::cmp::Reverse(item.1));
834        memory_hotspots.truncate(10); // Top 10
835
836        let peak_memory_usage = self.compute_peak_memory_usage(graph);
837        // No real memory-allocator/placement model exists in this crate --
838        // see the field's own doc comment. Honestly absent, not a
839        // fabricated "10% fragmented" guess.
840        let fragmentation_ratio = None;
841
842        let optimization_suggestions = vec![
843            "Consider memory pooling for frequently allocated tensors".to_string(),
844            "Implement in-place operations where possible".to_string(),
845            "Use gradient checkpointing for memory-intensive layers".to_string(),
846        ];
847
848        Ok(MemoryAnalysis {
849            total_memory_usage,
850            peak_memory_usage,
851            memory_by_operation,
852            memory_hotspots,
853            fragmentation_ratio,
854            optimization_suggestions,
855        })
856    }
857
858    /// Real peak simultaneously-live memory, via a liveness walk over the
859    /// graph's real topological order: at each node's execution step, its
860    /// output becomes live; a dependency's output is freed the moment the
861    /// *last* node (by topological position) that consumes it has
862    /// executed -- except outputs in [`ComputationGraph::leaf_nodes`],
863    /// which are the graph's own outputs and must stay live through the
864    /// end. The result is the maximum total live bytes observed at any
865    /// step; always `<= total_memory_usage` (equal only when nothing is
866    /// ever freed, i.e. every tensor really is live simultaneously).
867    fn compute_peak_memory_usage(&self, graph: &ComputationGraph) -> u64 {
868        let mut ordered: Vec<&GraphNode> = graph.nodes.values().collect();
869        ordered.sort_by_key(|n| n.topo_order.unwrap_or(usize::MAX));
870
871        // For every node, the topological position of its LAST consumer --
872        // the latest point at which its output is still needed as an
873        // input.
874        let mut last_use: HashMap<&str, usize> = HashMap::new();
875        for node in &ordered {
876            let Some(topo) = node.topo_order else {
877                continue;
878            };
879            for dep in graph.edges.get(&node.id).into_iter().flatten() {
880                last_use.entry(dep.as_str()).and_modify(|t| *t = (*t).max(topo)).or_insert(topo);
881            }
882        }
883
884        let mut live: u64 = 0;
885        let mut peak: u64 = 0;
886        for node in &ordered {
887            let Some(topo) = node.topo_order else {
888                continue;
889            };
890            live = live.saturating_add(node.memory_usage.unwrap_or(0));
891            peak = peak.max(live);
892            // Dedupe: an op can legitimately depend on the same upstream
893            // node twice (e.g. `Multiply(x, x)`), which would otherwise
894            // free `dep`'s memory once per OCCURRENCE in the edge list
895            // instead of once per real tensor -- an artificial extra
896            // free that could understate `live` (and therefore a LATER
897            // step's peak) even though nothing changed.
898            let unique_deps: HashSet<&str> =
899                graph.edges.get(&node.id).into_iter().flatten().map(|s| s.as_str()).collect();
900            for dep in unique_deps {
901                let is_last_use = last_use.get(dep) == Some(&topo);
902                if is_last_use && !graph.leaf_nodes.contains(dep) {
903                    if let Some(dep_node) = graph.nodes.get(dep) {
904                        live = live.saturating_sub(dep_node.memory_usage.unwrap_or(0));
905                    }
906                }
907            }
908        }
909        peak
910    }
911
912    fn analyze_flop_usage(&self, graph: &ComputationGraph) -> Result<FlopAnalysis> {
913        let total_flops = graph.nodes.values().filter_map(|n| n.flop_count).sum();
914
915        let mut flops_by_operation: HashMap<OperationType, u64> = HashMap::new();
916        for node in graph.nodes.values() {
917            if let Some(flops) = node.flop_count {
918                *flops_by_operation.entry(node.operation_type.clone()).or_insert(0) += flops;
919            }
920        }
921
922        let mut compute_hotspots: Vec<(String, u64)> = graph
923            .nodes
924            .values()
925            .filter_map(|n| n.flop_count.map(|f| (n.id.clone(), f)))
926            .collect();
927        compute_hotspots.sort_by_key(|item| std::cmp::Reverse(item.1));
928        compute_hotspots.truncate(10); // Top 10
929
930        let total_memory = graph.nodes.values().filter_map(|n| n.memory_usage).sum::<u64>();
931        let arithmetic_intensity =
932            if total_memory > 0 { total_flops as f64 / total_memory as f64 } else { 0.0 };
933
934        let complexity_analysis = ComplexityAnalysis {
935            // See the fields' own doc comments: a Big-O class is not
936            // derivable from one concrete-shaped graph instance.
937            time_complexity: None,
938            space_complexity: None,
939            parallelization_potential: self.compute_parallelization_potential(graph),
940            sequential_dependencies: graph.metadata.max_depth,
941        };
942
943        Ok(FlopAnalysis {
944            total_flops,
945            flops_by_operation,
946            compute_hotspots,
947            arithmetic_intensity,
948            complexity_analysis,
949        })
950    }
951
952    /// Real, structural parallelization-potential estimate: `1 -
953    /// span/work`, using UNIT per-node cost (i.e. "work" = node count,
954    /// "span" = critical-path length in nodes = `max_depth + 1`) -- the
955    /// classical work/span parallelism ratio from parallel-scheduling
956    /// theory (Brent/Graham), consistent with
957    /// [`ComplexityAnalysis::sequential_dependencies`] already being the
958    /// same unweighted `max_depth`. `0.0` for a pure chain (span == work:
959    /// every node is on the critical path, so nothing can run
960    /// alongside it); approaches `1.0` for a wide, shallow graph where
961    /// most nodes are off the critical path.
962    ///
963    /// Deliberately unweighted by FLOPs/bytes: those are per-node
964    /// quantities that can vary by orders of magnitude between nodes, so
965    /// a work-weighted version would let one disproportionately expensive
966    /// node dominate the ratio and mislabel a structurally wide (many
967    /// independent branches), maximally parallel graph as having "low"
968    /// potential just because most of its FLOPs happen to sit on the
969    /// critical path.
970    fn compute_parallelization_potential(&self, graph: &ComputationGraph) -> f64 {
971        let node_count = graph.nodes.len();
972        if node_count == 0 {
973            return 0.0;
974        }
975        let span = graph.metadata.max_depth + 1;
976        (1.0 - span as f64 / node_count as f64).clamp(0.0, 1.0)
977    }
978
979    fn detect_optimization_opportunities(
980        &self,
981        graph: &ComputationGraph,
982    ) -> Result<Vec<OptimizationOpportunity>> {
983        let mut opportunities = Vec::new();
984
985        // Look for operation fusion opportunities
986        opportunities.extend(self.detect_fusion_opportunities(graph)?);
987
988        // Look for redundant operations
989        opportunities.extend(self.detect_redundancy_opportunities(graph)?);
990
991        // Look for memory optimization opportunities
992        opportunities.extend(self.detect_memory_optimizations(graph)?);
993
994        Ok(opportunities)
995    }
996
997    fn detect_fusion_opportunities(
998        &self,
999        graph: &ComputationGraph,
1000    ) -> Result<Vec<OptimizationOpportunity>> {
1001        let mut opportunities = Vec::new();
1002
1003        // Look for patterns like MatMul + Add (bias addition)
1004        for node in graph.nodes.values() {
1005            if let OperationType::Add = node.operation_type {
1006                let empty_deps = vec![];
1007                let dependencies = graph.edges.get(&node.id).unwrap_or(&empty_deps);
1008                for dep in dependencies {
1009                    if let Some(dep_node) = graph.nodes.get(dep) {
1010                        if let OperationType::MatMul = dep_node.operation_type {
1011                            opportunities.push(OptimizationOpportunity {
1012                                optimization_type: OptimizationType::OperationFusion,
1013                                description:
1014                                    "Fuse MatMul and Add operations into a single GEMM operation"
1015                                        .to_string(),
1016                                affected_nodes: vec![dep.clone(), node.id.clone()],
1017                                estimated_improvement: EstimatedImprovement {
1018                                    speedup_factor: 1.2,
1019                                    memory_reduction: 1024 * 1024, // 1MB
1020                                    energy_savings: 0.1,
1021                                },
1022                                implementation_difficulty: 2,
1023                                priority: OptimizationPriority::Medium,
1024                            });
1025                        }
1026                    }
1027                }
1028            }
1029        }
1030
1031        Ok(opportunities)
1032    }
1033
1034    /// Real common-subexpression detection: groups nodes by the exact
1035    /// `(operation_type, ordered dependency list)` signature they compute.
1036    /// Two internal nodes applying the *same operation* to the *same
1037    /// ordered list of upstream node ids* are, for a deterministic pure
1038    /// operation, computing an identical result -- all but one are fully
1039    /// redundant. Dependency order is kept significant (never sorted), so
1040    /// non-commutative operations (`Subtract`, `MatMul`, ...) are never
1041    /// falsely flagged as duplicates of each other with swapped operands.
1042    ///
1043    /// [`ComputationGraph::root_nodes`] (no dependencies at all) are
1044    /// excluded: an empty dependency list carries no proof that two roots
1045    /// hold the same external data -- e.g. two distinct model inputs may
1046    /// well share both an operation type and "no dependencies" without
1047    /// being remotely the same tensor.
1048    fn detect_redundancy_opportunities(
1049        &self,
1050        graph: &ComputationGraph,
1051    ) -> Result<Vec<OptimizationOpportunity>> {
1052        let empty_deps: Vec<String> = Vec::new();
1053        let mut signature_groups: HashMap<(&OperationType, &[String]), Vec<&str>> = HashMap::new();
1054        for node in graph.nodes.values() {
1055            let deps = graph.edges.get(&node.id).unwrap_or(&empty_deps);
1056            if deps.is_empty() {
1057                continue; // no real dependencies to prove equivalence from
1058            }
1059            signature_groups
1060                .entry((&node.operation_type, deps.as_slice()))
1061                .or_default()
1062                .push(node.id.as_str());
1063        }
1064
1065        let mut opportunities = Vec::new();
1066        for ((op_type, deps), mut node_ids) in signature_groups {
1067            if node_ids.len() < 2 {
1068                continue;
1069            }
1070            node_ids.sort_unstable(); // deterministic report ordering
1071
1072            let redundant_count = node_ids.len() - 1;
1073            let per_node_memory = node_ids
1074                .iter()
1075                .filter_map(|id| graph.nodes.get(*id))
1076                .filter_map(|n| n.memory_usage)
1077                .max()
1078                .unwrap_or(0);
1079
1080            opportunities.push(OptimizationOpportunity {
1081                optimization_type: OptimizationType::RedundancyElimination,
1082                description: format!(
1083                    "{} node(s) recompute the identical {} over the same {} input(s); keep one \
1084                     and reuse its output for the other {}",
1085                    node_ids.len(),
1086                    op_type,
1087                    deps.len(),
1088                    redundant_count,
1089                ),
1090                affected_nodes: node_ids.iter().map(|s| s.to_string()).collect(),
1091                estimated_improvement: EstimatedImprovement {
1092                    // This group's own work shrinks from `node_ids.len()`
1093                    // identical computations to 1 -- a real factor derived
1094                    // from the actual duplicate count, not an invented
1095                    // constant.
1096                    speedup_factor: node_ids.len() as f64,
1097                    memory_reduction: per_node_memory * redundant_count as u64,
1098                    energy_savings: (redundant_count as f64 / node_ids.len() as f64)
1099                        .clamp(0.0, 1.0),
1100                },
1101                implementation_difficulty: 2,
1102                priority: if redundant_count >= 3 {
1103                    OptimizationPriority::High
1104                } else {
1105                    OptimizationPriority::Medium
1106                },
1107            });
1108        }
1109
1110        opportunities.sort_by(|a, b| a.affected_nodes.cmp(&b.affected_nodes));
1111        Ok(opportunities)
1112    }
1113
1114    fn detect_memory_optimizations(
1115        &self,
1116        graph: &ComputationGraph,
1117    ) -> Result<Vec<OptimizationOpportunity>> {
1118        let mut opportunities = Vec::new();
1119
1120        // Look for large memory operations
1121        for node in graph.nodes.values() {
1122            let Some(node_memory) = node.memory_usage else {
1123                // Nothing is known about this node's memory, so it cannot be
1124                // identified as a large one.
1125                continue;
1126            };
1127            if node_memory > self.config.large_memory_threshold {
1128                opportunities.push(OptimizationOpportunity {
1129                    optimization_type: OptimizationType::MemoryLayoutOptimization,
1130                    description: format!(
1131                        "Optimize memory layout for large operation: {}",
1132                        node.name
1133                    ),
1134                    affected_nodes: vec![node.id.clone()],
1135                    estimated_improvement: EstimatedImprovement {
1136                        speedup_factor: 1.1,
1137                        memory_reduction: node_memory / 4, // 25% reduction
1138                        energy_savings: 0.05,
1139                    },
1140                    implementation_difficulty: 3,
1141                    priority: OptimizationPriority::Medium,
1142                });
1143            }
1144        }
1145
1146        Ok(opportunities)
1147    }
1148
1149    fn analyze_bottlenecks(&self, graph: &ComputationGraph) -> Result<BottleneckAnalysis> {
1150        let mut bottleneck_nodes = Vec::new();
1151        let mut parallelizable_nodes = Vec::new();
1152
1153        for node in graph.nodes.values() {
1154            if let Some(exec_time) = node.execution_time_us {
1155                if exec_time > self.config.bottleneck_threshold_us {
1156                    bottleneck_nodes.push(node.id.clone());
1157                }
1158            }
1159
1160            // Check if node can be parallelized (simplified heuristic)
1161            match node.operation_type {
1162                OperationType::MatMul | OperationType::Conv2D | OperationType::Add => {
1163                    parallelizable_nodes.push(node.id.clone());
1164                },
1165                _ => {},
1166            }
1167        }
1168
1169        let critical_path_nodes = self.find_critical_path(graph)?;
1170        let critical_path_time_us = critical_path_nodes
1171            .iter()
1172            .filter_map(|id| graph.nodes.get(id))
1173            .filter_map(|node| node.execution_time_us)
1174            .sum();
1175
1176        let scheduling_suggestions = vec![
1177            "Consider parallel execution of independent operations".to_string(),
1178            "Use asynchronous execution for I/O operations".to_string(),
1179            "Implement pipeline parallelism for sequential operations".to_string(),
1180        ];
1181
1182        Ok(BottleneckAnalysis {
1183            bottleneck_nodes,
1184            critical_path_nodes,
1185            critical_path_time_us,
1186            parallelizable_nodes,
1187            scheduling_suggestions,
1188        })
1189    }
1190
1191    fn analyze_dataflow(&self, graph: &ComputationGraph) -> Result<DataFlowAnalysis> {
1192        let mut data_dependencies = HashMap::new();
1193        let mut live_variables = HashMap::new();
1194        for (node_id, dependencies) in &graph.edges {
1195            data_dependencies.insert(node_id.clone(), dependencies.clone());
1196            live_variables.insert(node_id.clone(), dependencies.iter().cloned().collect());
1197        }
1198
1199        // Real, deterministic variable lifetimes: each node's own OUTPUT
1200        // is one "variable", born when the node executes (its real
1201        // topological position) and alive until the topologically LAST
1202        // real consumer runs -- or, for a `leaf_nodes` member (the
1203        // graph's own published outputs), alive through the graph's end,
1204        // since nothing inside the graph marks when an external caller
1205        // is done reading it. Built from the real topo order (not
1206        // `graph.edges`' `HashMap` iteration order, which the previous
1207        // implementation used directly and which is not required to
1208        // reflect real execution sequence).
1209        let mut ordered: Vec<&GraphNode> = graph.nodes.values().collect();
1210        ordered.sort_by_key(|n| n.topo_order.unwrap_or(usize::MAX));
1211
1212        let mut consumers_by_dep: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
1213        for node in &ordered {
1214            let Some(topo) = node.topo_order else {
1215                continue;
1216            };
1217            for dep in graph.edges.get(&node.id).into_iter().flatten() {
1218                consumers_by_dep.entry(dep.as_str()).or_default().push((topo, node.id.as_str()));
1219            }
1220        }
1221        let max_topo = ordered.iter().filter_map(|n| n.topo_order).max().unwrap_or(0);
1222
1223        let mut variable_lifetimes = HashMap::new();
1224        let mut birth_death_topo: HashMap<&str, (usize, usize)> = HashMap::new();
1225        for node in &ordered {
1226            let Some(birth_topo) = node.topo_order else {
1227                continue;
1228            };
1229            let mut consumers = consumers_by_dep.get(node.id.as_str()).cloned().unwrap_or_default();
1230            consumers.sort(); // deterministic: (topo, consumer_id)
1231
1232            let death_topo = if graph.leaf_nodes.contains(&node.id) {
1233                max_topo
1234            } else {
1235                consumers.iter().map(|&(t, _)| t).max().unwrap_or(birth_topo)
1236            };
1237            // No real consumer at `death_topo` only when the node has no
1238            // consumers at all (it dies right at birth); otherwise this
1239            // is the real id of whichever consumer's topo position IS
1240            // `death_topo`.
1241            let death_node = consumers
1242                .iter()
1243                .find(|&&(t, _)| t == death_topo)
1244                .map(|&(_, id)| id.to_string())
1245                .unwrap_or_else(|| node.id.clone());
1246
1247            birth_death_topo.insert(node.id.as_str(), (birth_topo, death_topo));
1248            variable_lifetimes.insert(
1249                node.id.clone(),
1250                VariableLifetime {
1251                    birth_node: node.id.clone(),
1252                    death_node,
1253                    usage_nodes: consumers.iter().map(|&(_, id)| id.to_string()).collect(),
1254                    memory_footprint: node.memory_usage.unwrap_or(0),
1255                },
1256            );
1257        }
1258
1259        let memory_reuse_opportunities =
1260            self.find_memory_reuse_opportunities(graph, &ordered, &birth_death_topo);
1261
1262        Ok(DataFlowAnalysis {
1263            data_dependencies,
1264            live_variables,
1265            variable_lifetimes,
1266            memory_reuse_opportunities,
1267        })
1268    }
1269
1270    /// Real memory-reuse opportunities: pairs of internal (non-
1271    /// [`ComputationGraph::leaf_nodes`]) variables whose real lifetime
1272    /// intervals -- from [`Self::analyze_dataflow`]'s topo-order
1273    /// liveness computation -- do NOT overlap, so one variable's buffer
1274    /// could be physically reused for the other once the first is dead.
1275    /// `memory_savings` is the real `min(footprint_a, footprint_b)`:
1276    /// sizing one shared buffer to `max(a, b)` instead of allocating `a`
1277    /// and `b` separately saves exactly the smaller footprint.
1278    /// `complexity` is the real count of variables sharing the buffer
1279    /// (always `2` here, since this only ever proposes pairwise reuse) --
1280    /// not an editorial guess.
1281    ///
1282    /// Bounded to the `REUSE_CANDIDATE_LIMIT` largest-footprint
1283    /// variables to keep an otherwise-O(n^2) pairing tractable on large
1284    /// graphs; a documented performance bound, not a fabrication -- every
1285    /// opportunity actually reported is still real.
1286    fn find_memory_reuse_opportunities(
1287        &self,
1288        graph: &ComputationGraph,
1289        ordered: &[&GraphNode],
1290        birth_death_topo: &HashMap<&str, (usize, usize)>,
1291    ) -> Vec<MemoryReuseOpportunity> {
1292        const REUSE_CANDIDATE_LIMIT: usize = 200;
1293
1294        let mut candidates: Vec<&GraphNode> = ordered
1295            .iter()
1296            .filter(|n| n.memory_usage.is_some_and(|m| m > 0) && !graph.leaf_nodes.contains(&n.id))
1297            .copied()
1298            .collect();
1299        candidates.sort_by_key(|n| std::cmp::Reverse(n.memory_usage.unwrap_or(0)));
1300        candidates.truncate(REUSE_CANDIDATE_LIMIT);
1301
1302        let mut opportunities = Vec::new();
1303        for (i, &a) in candidates.iter().enumerate() {
1304            let Some(&(a_birth, a_death)) = birth_death_topo.get(a.id.as_str()) else {
1305                continue;
1306            };
1307            for &b in &candidates[i + 1..] {
1308                let Some(&(b_birth, b_death)) = birth_death_topo.get(b.id.as_str()) else {
1309                    continue;
1310                };
1311                // Real non-overlap: does one variable's lifetime end
1312                // strictly before the other's begins? (Strict, not `<=`:
1313                // equality would mean one directly consumes the other at
1314                // that exact step, which is not a safe blind reuse.)
1315                let non_overlapping = a_death < b_birth || b_death < a_birth;
1316                if !non_overlapping {
1317                    continue;
1318                }
1319                let savings = a.memory_usage.unwrap_or(0).min(b.memory_usage.unwrap_or(0));
1320                if savings == 0 {
1321                    continue;
1322                }
1323                let mut reusable_variables = vec![a.id.clone(), b.id.clone()];
1324                reusable_variables.sort();
1325                opportunities.push(MemoryReuseOpportunity {
1326                    reusable_variables,
1327                    memory_savings: savings,
1328                    complexity: 2,
1329                });
1330            }
1331        }
1332
1333        opportunities.sort_by_key(|o| std::cmp::Reverse(o.memory_savings));
1334        opportunities.truncate(10);
1335        opportunities
1336    }
1337
1338    /// Real critical path: the longest weighted path through the
1339    /// dependency DAG, found by dynamic programming over the graph's real
1340    /// topological order (replacing the old "depth as proxy" heuristic,
1341    /// which only ever reported path *length*, never the actual
1342    /// highest-cost chain, and in fact walked every other depth level due
1343    /// to a double-decrement bug).
1344    ///
1345    /// Every node in the graph is weighted on the SAME scale: real
1346    /// profiled `execution_time_us` when *any* node has been profiled
1347    /// (unprofiled nodes contribute `0`, never compared against a
1348    /// different unit), falling back to real estimated `flop_count` for
1349    /// every node only when none of the graph has been profiled yet --
1350    /// deciding this once per graph (not per node) so a `Some(50)` µs
1351    /// node is never pitted against a `1_000_000`-FLOP node in the same
1352    /// path sum.
1353    fn find_critical_path(&self, graph: &ComputationGraph) -> Result<Vec<String>> {
1354        let mut ordered: Vec<&GraphNode> = graph.nodes.values().collect();
1355        ordered.sort_by_key(|n| n.topo_order.unwrap_or(usize::MAX));
1356
1357        let use_time = graph.nodes.values().any(|n| n.execution_time_us.is_some());
1358        let weight = |node: &GraphNode| -> f64 {
1359            if use_time {
1360                node.execution_time_us.unwrap_or(0) as f64
1361            } else {
1362                // A node with no FLOP estimate contributes no weight to the
1363                // critical path rather than a fabricated cost.
1364                node.flop_count.unwrap_or(0) as f64
1365            }
1366        };
1367
1368        // best_cost[node] = weight of the longest path ending at `node`;
1369        // predecessor[node] = the dependency that achieves it, for
1370        // backtracking the actual path afterwards.
1371        let mut best_cost: HashMap<&str, f64> = HashMap::new();
1372        let mut predecessor: HashMap<&str, &str> = HashMap::new();
1373
1374        for node in &ordered {
1375            if node.topo_order.is_none() {
1376                continue;
1377            }
1378            let mut best_dep_cost = 0.0_f64;
1379            let mut best_dep: Option<&str> = None;
1380            for dep in graph.edges.get(&node.id).into_iter().flatten() {
1381                if let Some(&cost) = best_cost.get(dep.as_str()) {
1382                    // Deterministic tie-break (lexicographically greater
1383                    // dep id wins) so results don't depend on HashMap
1384                    // iteration order.
1385                    let better = match best_dep {
1386                        None => true,
1387                        Some(bd) => {
1388                            cost > best_dep_cost || (cost == best_dep_cost && dep.as_str() > bd)
1389                        },
1390                    };
1391                    if better {
1392                        best_dep_cost = cost;
1393                        best_dep = Some(dep.as_str());
1394                    }
1395                }
1396            }
1397            best_cost.insert(node.id.as_str(), weight(node) + best_dep_cost);
1398            if let Some(dep) = best_dep {
1399                predecessor.insert(node.id.as_str(), dep);
1400            }
1401        }
1402
1403        // The critical path ends at whichever node has the largest total
1404        // cost -- the real sink of the longest chain, not necessarily a
1405        // declared `leaf_nodes` member on a multi-output graph. Ties break
1406        // deterministically on node id.
1407        let Some((&end_node, _)) = best_cost.iter().max_by(|a, b| {
1408            a.1.partial_cmp(b.1)
1409                .unwrap_or(std::cmp::Ordering::Equal)
1410                .then_with(|| a.0.cmp(b.0))
1411        }) else {
1412            return Ok(Vec::new());
1413        };
1414
1415        let mut path = vec![end_node.to_string()];
1416        let mut current = end_node;
1417        while let Some(&pred) = predecessor.get(current) {
1418            path.push(pred.to_string());
1419            current = pred;
1420        }
1421        path.reverse();
1422        Ok(path)
1423    }
1424
1425    fn calculate_statistics(&self, graph: &ComputationGraph) -> Result<GraphStatistics> {
1426        let mut nodes_by_type: HashMap<OperationType, usize> = HashMap::new();
1427        for node in graph.nodes.values() {
1428            *nodes_by_type.entry(node.operation_type.clone()).or_insert(0) += 1;
1429        }
1430
1431        let total_fan_in: usize = graph.edges.values().map(|deps| deps.len()).sum();
1432        let total_fan_out = total_fan_in; // In a DAG, total fan-in equals total fan-out
1433        let average_fan_in = total_fan_in as f64 / graph.nodes.len() as f64;
1434        let average_fan_out = total_fan_out as f64 / graph.nodes.len() as f64;
1435
1436        Ok(GraphStatistics {
1437            nodes_by_type,
1438            average_fan_in,
1439            average_fan_out,
1440            diameter: graph.metadata.max_depth,
1441            clustering_coefficient: self.compute_clustering_coefficient(graph),
1442            strongly_connected_components: graph.nodes.len(), // Each node is its own SCC in a DAG
1443        })
1444    }
1445
1446    /// Real average local clustering coefficient (Watts-Strogatz), computed
1447    /// on the graph's *undirected* neighbor relation: a dependency edge
1448    /// `dep -> node` makes `dep` and `node` neighbors regardless of
1449    /// direction, the conventional way to compute this statistic on a
1450    /// directed graph. For each node `v` with `k_v` neighbors,
1451    /// `C_v = (edges among v's neighbors) / (k_v * (k_v - 1) / 2)`;
1452    /// nodes with fewer than 2 neighbors contribute `0` (the standard
1453    /// convention: no pair of neighbors exists to be connected or not).
1454    /// The graph-level value is the mean of `C_v` over all nodes.
1455    ///
1456    /// This is *not* trivially `0.0` the way the old placeholder claimed:
1457    /// a "diamond"/skip-connection pattern -- a value feeding both an
1458    /// operation and that operation's own downstream consumer, e.g.
1459    /// `x -> f(x)` followed by `Add(x, f(x))` -- makes `x`'s two
1460    /// consumers neighbors of *each other* too (since one feeds the
1461    /// other), producing a real triangle and a nonzero `C_v`. This exact
1462    /// shape is common in transformer graphs (residual connections).
1463    fn compute_clustering_coefficient(&self, graph: &ComputationGraph) -> f64 {
1464        if graph.nodes.is_empty() {
1465            return 0.0;
1466        }
1467
1468        let mut neighbors: HashMap<&str, HashSet<&str>> = HashMap::new();
1469        for node_id in graph.nodes.keys() {
1470            neighbors.entry(node_id.as_str()).or_default();
1471        }
1472        for (node_id, deps) in &graph.edges {
1473            for dep in deps {
1474                neighbors.entry(node_id.as_str()).or_default().insert(dep.as_str());
1475                neighbors.entry(dep.as_str()).or_default().insert(node_id.as_str());
1476            }
1477        }
1478
1479        let mut coefficient_sum = 0.0;
1480        for neighs in neighbors.values() {
1481            let k = neighs.len();
1482            if k < 2 {
1483                continue; // contributes 0, per convention
1484            }
1485            let neigh_vec: Vec<&str> = neighs.iter().copied().collect();
1486            let mut connected_pairs = 0usize;
1487            for (i, &a) in neigh_vec.iter().enumerate() {
1488                for &b in &neigh_vec[i + 1..] {
1489                    if neighbors.get(a).is_some_and(|n| n.contains(b)) {
1490                        connected_pairs += 1;
1491                    }
1492                }
1493            }
1494            let possible_pairs = k * (k - 1) / 2;
1495            coefficient_sum += connected_pairs as f64 / possible_pairs as f64;
1496        }
1497
1498        coefficient_sum / graph.nodes.len() as f64
1499    }
1500
1501    fn generate_recommendations(&self, analysis: &GraphAnalysisResult) -> Result<Vec<String>> {
1502        let mut recommendations = Vec::new();
1503
1504        // Memory-based recommendations
1505        if let Some(ref memory_analysis) = analysis.memory_analysis {
1506            if memory_analysis.total_memory_usage > 1024 * 1024 * 1024 {
1507                // > 1GB
1508                recommendations.push(
1509                    "Consider using gradient checkpointing to reduce memory usage".to_string(),
1510                );
1511            }
1512            if let Some(ratio) = memory_analysis.fragmentation_ratio {
1513                if ratio > 0.2 {
1514                    recommendations
1515                        .push("Implement memory pooling to reduce fragmentation".to_string());
1516                }
1517            }
1518        }
1519
1520        // FLOP-based recommendations
1521        if let Some(ref flop_analysis) = analysis.flop_analysis {
1522            if flop_analysis.arithmetic_intensity < 1.0 {
1523                recommendations
1524                    .push("Consider kernel fusion to improve arithmetic intensity".to_string());
1525            }
1526            if flop_analysis.complexity_analysis.parallelization_potential > 0.5 {
1527                recommendations.push(
1528                    "Explore parallelization opportunities for compute-intensive operations"
1529                        .to_string(),
1530                );
1531            }
1532        }
1533
1534        // Optimization opportunities
1535        if analysis.optimization_opportunities.len() > 3 {
1536            recommendations.push(
1537                "Multiple optimization opportunities detected - prioritize by estimated impact"
1538                    .to_string(),
1539            );
1540        }
1541
1542        // Bottleneck recommendations
1543        if let Some(ref bottleneck_analysis) = analysis.bottleneck_analysis {
1544            if !bottleneck_analysis.bottleneck_nodes.is_empty() {
1545                recommendations.push(
1546                    "Address bottleneck operations through optimization or parallelization"
1547                        .to_string(),
1548                );
1549            }
1550        }
1551
1552        Ok(recommendations)
1553    }
1554
1555    fn get_node_color(&self, op_type: &OperationType) -> &'static str {
1556        match op_type {
1557            OperationType::MatMul | OperationType::Dot => "lightblue",
1558            OperationType::Add
1559            | OperationType::Subtract
1560            | OperationType::Multiply
1561            | OperationType::Divide => "lightgreen",
1562            OperationType::ReLU
1563            | OperationType::Sigmoid
1564            | OperationType::Tanh
1565            | OperationType::GELU => "orange",
1566            OperationType::LayerNorm | OperationType::BatchNorm | OperationType::RMSNorm => {
1567                "yellow"
1568            },
1569            OperationType::Conv1D | OperationType::Conv2D | OperationType::Conv3D => "lightcoral",
1570            OperationType::Attention | OperationType::MultiHeadAttention => "purple",
1571            OperationType::Embedding | OperationType::PositionalEmbedding => "pink",
1572            _ => "lightgray",
1573        }
1574    }
1575}
1576
1577impl fmt::Display for OperationType {
1578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1579        match self {
1580            OperationType::Custom(name) => write!(f, "Custom({})", name),
1581            _ => write!(f, "{:?}", self),
1582        }
1583    }
1584}
1585
1586impl Default for ComputationGraphAnalyzer {
1587    fn default() -> Self {
1588        Self::new(GraphAnalysisConfig::default())
1589    }
1590}
1591
1592#[cfg(test)]
1593#[path = "computation_graph_tests.rs"]
1594mod tests;