Skip to main content

sqlitegraph/algo/
critical_path.rs

1//! Critical path analysis (longest weighted path in DAG).
2//!
3//! This module provides algorithms for computing the critical path in directed
4//! acyclic graphs (DAGs). The critical path is the longest weighted path from
5//! any source node to any sink node, representing the minimum completion time
6//! for dependency-ordered tasks.
7//!
8//! # Available Algorithms
9//!
10//! - [`critical_path`] - Compute critical path with custom weight function
11//! - [`critical_path_with_progress`] - Critical path with progress tracking
12//! - [`CriticalPathResult`] - Result with path, distance, bottlenecks, and slack
13//! - [`CriticalPathError`] - Error type for non-DAG graphs
14//!
15//! # When to Use Critical Path Analysis
16//!
17//! - **Build systems** - Identify minimum build time and bottlenecks
18//! - **Task scheduling** - Find which tasks determine completion time
19//! - **Project management** - Critical path method for project planning
20//! - **Dependency analysis** - Identify bottleneck dependencies
21//!
22//! # Algorithm
23//!
24//! Two-phase approach:
25//! 1. **Topological sort** - Validate DAG and compute linear ordering
26//! 2. **Dynamic programming** - Process nodes in topological order, computing
27//!    longest distance to each node using MAX relaxation (not MIN like shortest path)
28//!
29//! # Why Longest Path?
30//!
31//! Unlike shortest path (which works for any graph), longest path is NP-hard
32//! for general graphs but polynomial-time for DAGs. For dependency graphs:
33//! - Dependencies add up (total time = sum of task durations)
34//! - Bottleneck tasks (on critical path) cannot be parallelized
35//! - Delaying any task on critical path delays entire project
36//!
37//! # Complexity
38//!
39//! - **Time**: O(|V| + |E|) - Topological sort is O(|V| + |E|), DP is O(|V| + |E|)
40//! - **Space**: O(|V|) for distances, predecessors, and topological order
41//!
42//! # Example
43//!
44//! ```rust,ignore
45//! use sqlitegraph::{SqliteGraph, algo::critical_path};
46//!
47//! let graph = SqliteGraph::open_in_memory()?;
48//! // ... add nodes and edges with weights ...
49//!
50//! // Use default weight (1.0 for all edges)
51//! let result = critical_path(&graph, &default_weight_fn)?;
52//!
53//! println!("Critical path: {:?}", result.path);
54//! println!("Minimum completion time: {}", result.distance);
55//! println!("Bottlenecks: {:?}", result.bottlenecks());
56//!
57//! // Check slack for each task
58//! for (node, slack) in result.slack() {
59//!     if slack == 0.0 {
60//!         println!("Node {} is on critical path (no slack)", node);
61//!     } else {
62//!         println!("Node {} can be delayed by {}", node, slack);
63//!     }
64//! }
65//! ```
66
67use std::fmt;
68
69use ahash::{AHashMap, AHashSet};
70use serde_json::Value;
71
72use crate::algo::topological_sort::{topological_sort, TopoError};
73use crate::graph::SqliteGraph;
74use crate::progress::ProgressCallback;
75
76/// Error type for critical path analysis.
77///
78/// Critical path analysis requires a DAG (directed acyclic graph).
79/// When cycles exist, this error provides the cycle path for debugging.
80#[derive(Debug, Clone)]
81pub enum CriticalPathError {
82    /// Graph contains a cycle, making critical path undefined.
83    ///
84    /// The `cycle` field contains the actual cycle path (nodes forming the cycle).
85    /// For dependency graphs, this indicates circular dependencies that must be resolved.
86    NotADag {
87        /// Nodes forming the cycle (in order).
88        cycle: Vec<i64>,
89    },
90
91    /// Edge weight could not be extracted or is invalid.
92    ///
93    /// This occurs when the weight callback returns NaN or infinity,
94    /// or when edge data cannot be accessed.
95    InvalidWeight {
96        /// Source node of the edge.
97        from: i64,
98        /// Target node of the edge.
99        to: i64,
100        /// Description of why the weight is invalid.
101        reason: String,
102    },
103}
104
105impl fmt::Display for CriticalPathError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            CriticalPathError::NotADag { cycle } => {
109                write!(
110                    f,
111                    "Critical path analysis requires a DAG: cycle detected: {:?}",
112                    cycle
113                )
114            }
115            CriticalPathError::InvalidWeight { from, to, reason } => {
116                write!(
117                    f,
118                    "Invalid weight for edge {} -> {}: {}",
119                    from, to, reason
120                )
121            }
122        }
123    }
124}
125
126impl std::error::Error for CriticalPathError {}
127
128/// Result of critical path analysis on a DAG.
129///
130/// Contains the longest weighted path (critical path) and distance
131/// information for all nodes. The critical path represents the
132/// bottleneck in dependency chains—the sequence that determines
133/// minimum completion time.
134///
135/// # Fields
136///
137/// - `path` - The critical path nodes in order from source to sink
138/// - `distance` - Total weight of the critical path (minimum completion time)
139/// - `distances` - Maximum distance from any source to each node
140/// - `predecessors` - Predecessor map for path reconstruction
141/// - `topological_order` - Topological order used for computation
142#[derive(Debug, Clone)]
143pub struct CriticalPathResult {
144    /// The critical path: nodes in order from source to sink.
145    /// This is the longest weighted path in the DAG.
146    pub path: Vec<i64>,
147
148    /// Total weight of the critical path.
149    /// For build systems, this is the minimum completion time.
150    pub distance: f64,
151
152    /// Maximum distance from any source to each node.
153    /// Maps node ID -> longest distance to reach that node.
154    pub distances: AHashMap<i64, f64>,
155
156    /// Predecessor map for path reconstruction.
157    /// Maps node ID -> previous node on critical path (None for source nodes).
158    pub predecessors: AHashMap<i64, Option<i64>>,
159
160    /// Topological order used for computation.
161    pub topological_order: Vec<i64>,
162}
163
164impl CriticalPathResult {
165    /// Returns the bottleneck nodes (nodes on critical path).
166    ///
167    /// Nodes on the critical path have zero slack—if any of these
168    /// tasks are delayed, the entire project is delayed.
169    ///
170    /// # Example
171    ///
172    /// ```rust,ignore
173    /// # use sqlitegraph::algo::critical_path::CriticalPathResult;
174    /// # let result: CriticalPathResult = unimplemented!();
175    /// let bottlenecks = result.bottlenecks();
176    /// for node in bottlenecks {
177    ///     println!("Node {} is a bottleneck", node);
178    /// }
179    /// ```
180    pub fn bottlenecks(&self) -> AHashSet<i64> {
181        self.path.iter().copied().collect()
182    }
183
184    /// Returns the slack for each node.
185    ///
186    /// Slack is the amount of time a task can be delayed without
187    /// delaying the entire project. Nodes on the critical path have
188    /// zero slack. Nodes with positive slack can be delayed.
189    ///
190    /// Slack = longest_distance - node_distance
191    ///
192    /// # Example
193    ///
194    /// ```rust,ignore
195    /// # use sqlitegraph::algo::critical_path::CriticalPathResult;
196    /// # let result: CriticalPathResult = unimplemented!();
197    /// for (node, slack) in result.slack() {
198    ///     if slack == 0.0 {
199    ///         println!("Node {}: critical (no slack)", node);
200    ///     } else {
201    ///         println!("Node {}: {} units of slack", node, slack);
202    ///     }
203    /// }
204    /// ```
205    pub fn slack(&self) -> AHashMap<i64, f64> {
206        self.distances
207            .iter()
208            .map(|(&node, &dist)| (node, self.distance - dist))
209            .collect()
210    }
211
212    /// Checks if a node is on the critical path (is a bottleneck).
213    ///
214    /// Returns `true` if the node is on the critical path, meaning
215    /// delaying this task will delay the entire project.
216    ///
217    /// # Example
218    ///
219    /// ```rust,ignore
220    /// # use sqlitegraph::algo::critical_path::CriticalPathResult;
221    /// # let result: CriticalPathResult = unimplemented!();
222    /// if result.is_bottleneck(task_id) {
223    ///     println!("Task {} is critical - cannot be delayed", task_id);
224    /// }
225    /// ```
226    pub fn is_bottleneck(&self, node: i64) -> bool {
227        self.path.contains(&node)
228    }
229}
230
231/// Weight callback type for edge weighting.
232///
233/// Given a source node, target node, and edge data, returns the weight
234/// of that edge. Weights must be finite (not NaN or infinity).
235///
236/// # Example
237///
238/// ```rust,ignore
239/// use sqlitegraph::algo::critical_path::WeightCallback;
240/// use serde_json::json;
241///
242/// let weight_fn: &WeightCallback = &|from, to, edge_data| {
243///     edge_data
244///         .get("duration")
245///         .and_then(|v| v.as_f64())
246///         .unwrap_or(1.0)
247/// };
248/// ```
249pub type WeightCallback = dyn Fn(i64, i64, &Value) -> f64;
250
251/// Default weight function that returns 1.0 for all edges.
252///
253/// Use this for unweighted DAGs where edge weights don't matter.
254/// The critical path will be the longest path in terms of hop count.
255///
256/// # Example
257///
258/// ```rust,ignore
259/// use sqlitegraph::{SqliteGraph, algo::critical_path::{critical_path, default_weight_fn}};
260///
261/// let graph = SqliteGraph::open_in_memory()?;
262/// // ... build unweighted DAG ...
263///
264/// let result = critical_path(&graph, &default_weight_fn)?;
265/// println!("Critical path has {} hops", result.distance as i64);
266/// ```
267pub fn default_weight_fn(_from: i64, _to: i64, _edge_data: &Value) -> f64 {
268    1.0
269}
270
271/// Computes the critical path (longest weighted path) in a DAG.
272///
273/// Critical path analysis finds the longest weighted path from any source
274/// node to any sink node. This represents the minimum completion time for
275/// dependency-ordered tasks and identifies bottleneck tasks.
276///
277/// # Arguments
278///
279/// * `graph` - The DAG to analyze
280/// * `weight_fn` - Callback to extract edge weight from (from, to, edge_data)
281///
282/// # Returns
283///
284/// `Ok(CriticalPathResult)` containing the critical path, total distance,
285/// per-node distances, predecessors, and topological order.
286///
287/// `Err(CriticalPathError::NotADag)` if the graph contains cycles.
288///
289/// # Algorithm
290///
291/// 1. **Topological sort** - Validate DAG and compute node ordering
292/// 2. **Initialize distances** - All nodes start at distance 0 (multi-source)
293/// 3. **Process in order** - For each node, relax outgoing edges with MAX
294/// 4. **Find maximum** - Identify node with largest distance
295/// 5. **Reconstruct path** - Follow predecessor pointers from sink to source
296///
297/// # Complexity
298///
299/// - **Time**: O(|V| + |E|)
300/// - **Space**: O(|V|)
301///
302/// # Edge Cases
303///
304/// - **Empty graph**: Returns result with empty path, distance = 0
305/// - **Single node**: Returns result with path = [node], distance = 0
306/// - **Disconnected DAG**: Finds longest path across all components
307/// - **Cyclic graph**: Returns `Err(CriticalPathError::NotADag)` with cycle path
308///
309/// # Example
310///
311/// ```rust,ignore
312/// use sqlitegraph::{SqliteGraph, algo::critical_path::{critical_path, default_weight_fn}};
313///
314/// let graph = SqliteGraph::open_in_memory()?;
315/// // ... build DAG ...
316///
317/// match critical_path(&graph, &default_weight_fn) {
318///     Ok(result) => {
319///         println!("Critical path: {:?}", result.path);
320///         println!("Minimum completion time: {}", result.distance);
321///         println!("Bottlenecks: {:?}", result.bottlenecks());
322///     }
323///     Err(CriticalPathError::NotADag { cycle }) => {
324///         eprintln!("Circular dependencies detected: {:?}", cycle);
325///     }
326/// }
327/// ```
328pub fn critical_path(
329    graph: &SqliteGraph,
330    weight_fn: &WeightCallback,
331) -> Result<CriticalPathResult, CriticalPathError> {
332    // Step 1: Validate DAG and get topological order
333    let topo_order = topological_sort(graph).map_err(|e| match e {
334        TopoError::CycleDetected { cycle, .. } => CriticalPathError::NotADag { cycle },
335    })?;
336
337    // Handle empty graph
338    if topo_order.is_empty() {
339        return Ok(CriticalPathResult {
340            path: Vec::new(),
341            distance: 0.0,
342            distances: AHashMap::new(),
343            predecessors: AHashMap::new(),
344            topological_order: Vec::new(),
345        });
346    }
347
348    // Step 2: Initialize distances to 0 (multi-source: all nodes start at 0)
349    let mut distances: AHashMap<i64, f64> = AHashMap::new();
350    let mut predecessors: AHashMap<i64, Option<i64>> = AHashMap::new();
351
352    for &node in &topo_order {
353        distances.insert(node, 0.0);
354        predecessors.insert(node, None);
355    }
356
357    // Step 3: Process vertices in topological order, relaxing edges with MAX
358    for &u in &topo_order {
359        let dist_u = *distances.get(&u).unwrap_or(&0.0);
360
361        // Get outgoing edges for node u
362        let outgoing = graph
363            .fetch_outgoing(u)
364            .map_err(|e| CriticalPathError::InvalidWeight {
365                from: u,
366                to: 0, // Unknown target
367                reason: format!("failed to fetch outgoing edges: {}", e),
368            })?;
369
370        for v in outgoing {
371            // Note: Edge data lookup by (from, to) is not directly supported.
372            // Pass empty JSON data - weight_fn should use default weight or fetch edge differently.
373            let edge_data = &serde_json::json!({});
374
375            let weight = weight_fn(u, v, edge_data);
376
377            // Validate weight
378            if !weight.is_finite() {
379                return Err(CriticalPathError::InvalidWeight {
380                    from: u,
381                    to: v,
382                    reason: format!("weight is not finite: {}", weight),
383                });
384            }
385
386            let new_dist = dist_u + weight;
387            let dist_v = distances.get_mut(&v).unwrap();
388
389            // Use MAX for longest path (opposite of shortest path)
390            if new_dist > *dist_v {
391                *dist_v = new_dist;
392                predecessors.insert(v, Some(u));
393            }
394        }
395    }
396
397    // Step 4: Find node with maximum distance (sink of critical path)
398    let mut max_distance = 0.0;
399    let mut end_node = None;
400
401    for (&node, &dist) in &distances {
402        if dist > max_distance {
403            max_distance = dist;
404            end_node = Some(node);
405        }
406    }
407
408    // Handle case where no path exists (all distances = 0)
409    let end_node = match end_node {
410        Some(node) => node,
411        None => {
412            // All nodes have distance 0, return any node as path
413            let first = topo_order.first().copied().unwrap_or(0);
414            return Ok(CriticalPathResult {
415                path: vec![first],
416                distance: 0.0,
417                distances,
418                predecessors,
419                topological_order: topo_order,
420            });
421        }
422    };
423
424    // Step 5: Reconstruct critical path by following predecessors
425    let mut path = Vec::new();
426    let mut current = Some(end_node);
427
428    while let Some(node) = current {
429        path.push(node);
430        current = *predecessors.get(&node).unwrap_or(&None);
431    }
432
433    path.reverse();
434
435    Ok(CriticalPathResult {
436        path,
437        distance: max_distance,
438        distances,
439        predecessors,
440        topological_order: topo_order,
441    })
442}
443
444/// Computes the critical path with progress tracking.
445///
446/// Same as [`critical_path`] but reports progress during execution.
447/// Suitable for long-running critical path analysis on large graphs.
448///
449/// # Progress Stages
450///
451/// 1. "Validating DAG structure" - Topological sort
452/// 2. "Computing critical path" - Longest path DP
453/// 3. "Reconstructing path" - Building the path from predecessors
454///
455/// # Arguments
456///
457/// * `graph` - The DAG to analyze
458/// * `weight_fn` - Callback to extract edge weight
459/// * `progress` - Progress callback for status updates
460///
461/// # Example
462///
463/// ```rust,ignore
464/// use sqlitegraph::{
465///     SqliteGraph,
466///     algo::critical_path::{critical_path_with_progress, default_weight_fn},
467///     progress::ConsoleProgress
468/// };
469///
470/// let graph = SqliteGraph::open_in_memory()?;
471/// // ... build large DAG ...
472///
473/// let progress = ConsoleProgress::new();
474/// let result = critical_path_with_progress(&graph, &default_weight_fn, &progress)?;
475/// ```
476pub fn critical_path_with_progress<F>(
477    graph: &SqliteGraph,
478    weight_fn: &WeightCallback,
479    progress: &F,
480) -> Result<CriticalPathResult, CriticalPathError>
481where
482    F: ProgressCallback,
483{
484    // Stage 1: Validate DAG
485    progress.on_progress(1, Some(3), "Validating DAG structure");
486
487    let topo_order = topological_sort(graph).map_err(|e| match e {
488        TopoError::CycleDetected { cycle, .. } => CriticalPathError::NotADag { cycle },
489    })?;
490
491    // Handle empty graph
492    if topo_order.is_empty() {
493        progress.on_complete();
494        return Ok(CriticalPathResult {
495            path: Vec::new(),
496            distance: 0.0,
497            distances: AHashMap::new(),
498            predecessors: AHashMap::new(),
499            topological_order: Vec::new(),
500        });
501    }
502
503    // Stage 2: Compute longest path
504    progress.on_progress(2, Some(3), "Computing critical path");
505
506    let total_nodes = topo_order.len();
507    let mut distances: AHashMap<i64, f64> = AHashMap::new();
508    let mut predecessors: AHashMap<i64, Option<i64>> = AHashMap::new();
509
510    for &node in &topo_order {
511        distances.insert(node, 0.0);
512        predecessors.insert(node, None);
513    }
514
515    for (i, &u) in topo_order.iter().enumerate() {
516        let dist_u = *distances.get(&u).unwrap_or(&0.0);
517
518        let outgoing = graph
519            .fetch_outgoing(u)
520            .map_err(|e| CriticalPathError::InvalidWeight {
521                from: u,
522                to: 0,
523                reason: format!("failed to fetch outgoing edges: {}", e),
524            })?;
525
526        for v in outgoing {
527            // Note: Edge data lookup by (from, to) is not directly supported.
528            // Pass empty JSON data - weight_fn should use default weight or fetch edge differently.
529            let edge_data = &serde_json::json!({});
530
531            let weight = weight_fn(u, v, edge_data);
532
533            if !weight.is_finite() {
534                return Err(CriticalPathError::InvalidWeight {
535                    from: u,
536                    to: v,
537                    reason: format!("weight is not finite: {}", weight),
538                });
539            }
540
541            let new_dist = dist_u + weight;
542            let dist_v = distances.get_mut(&v).unwrap();
543
544            if new_dist > *dist_v {
545                *dist_v = new_dist;
546                predecessors.insert(v, Some(u));
547            }
548        }
549
550        // Report progress for each node processed
551        progress.on_progress(i + 1, Some(total_nodes), "Processing nodes");
552    }
553
554    // Find maximum distance node
555    let mut max_distance = 0.0;
556    let mut end_node = None;
557
558    for (&node, &dist) in &distances {
559        if dist > max_distance {
560            max_distance = dist;
561            end_node = Some(node);
562        }
563    }
564
565    let end_node = match end_node {
566        Some(node) => node,
567        None => {
568            let first = topo_order.first().copied().unwrap_or(0);
569            progress.on_complete();
570            return Ok(CriticalPathResult {
571                path: vec![first],
572                distance: 0.0,
573                distances,
574                predecessors,
575                topological_order: topo_order,
576            });
577        }
578    };
579
580    // Stage 3: Reconstruct path
581    progress.on_progress(3, Some(3), "Reconstructing path");
582
583    let mut path = Vec::new();
584    let mut current = Some(end_node);
585
586    while let Some(node) = current {
587        path.push(node);
588        current = *predecessors.get(&node).unwrap_or(&None);
589    }
590
591    path.reverse();
592
593    progress.on_complete();
594
595    Ok(CriticalPathResult {
596        path,
597        distance: max_distance,
598        distances,
599        predecessors,
600        topological_order: topo_order,
601    })
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::{GraphEntity, GraphEdge};
608
609    /// Helper: Create test graph with linear chain: A --5--> B --3--> C --2--> D
610    fn create_linear_weighted_dag() -> SqliteGraph {
611        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
612
613        // Create 4 nodes
614        for i in 0..4 {
615            let entity = GraphEntity {
616                id: 0,
617                kind: "task".to_string(),
618                name: format!("task_{}", i),
619                file_path: Some(format!("task_{}.rs", i)),
620                data: serde_json::json!({"index": i}),
621            };
622            graph.insert_entity(&entity).expect("Failed to insert entity");
623        }
624
625        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
626
627        // Create weighted chain: 0 --5--> 1 --3--> 2 --2--> 3
628        let weights = vec![5.0, 3.0, 2.0];
629        for (i, &weight) in weights.iter().enumerate() {
630            let edge = GraphEdge {
631                id: 0,
632                from_id: entity_ids[i],
633                to_id: entity_ids[i + 1],
634                edge_type: "depends".to_string(),
635                data: serde_json::json!({"duration": weight}),
636            };
637            graph.insert_edge(&edge).expect("Failed to insert edge");
638        }
639
640        graph
641    }
642
643    /// Helper: Create diamond DAG with weighted edges
644    ///     A
645    ///    / \
646    ///   5   3
647    ///  /     \
648    /// B       C
649    ///  \     /
650    ///   4   2
651    ///    \ /
652    ///     D
653    fn create_diamond_weighted_dag() -> SqliteGraph {
654        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
655
656        // Create 4 nodes: A, B, C, D
657        for i in 0..4 {
658            let entity = GraphEntity {
659                id: 0,
660                kind: "task".to_string(),
661                name: format!("task_{}", i),
662                file_path: Some(format!("task_{}.rs", i)),
663                data: serde_json::json!({"index": i}),
664            };
665            graph.insert_entity(&entity).expect("Failed to insert entity");
666        }
667
668        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
669
670        // A -> B (weight 5), B -> D (weight 4)
671        let edge1 = GraphEdge {
672            id: 0,
673            from_id: entity_ids[0],
674            to_id: entity_ids[1],
675            edge_type: "depends".to_string(),
676            data: serde_json::json!({"duration": 5.0}),
677        };
678        graph.insert_edge(&edge1).expect("Failed to insert edge");
679
680        let edge2 = GraphEdge {
681            id: 0,
682            from_id: entity_ids[1],
683            to_id: entity_ids[3],
684            edge_type: "depends".to_string(),
685            data: serde_json::json!({"duration": 4.0}),
686        };
687        graph.insert_edge(&edge2).expect("Failed to insert edge");
688
689        // A -> C (weight 3), C -> D (weight 2)
690        let edge3 = GraphEdge {
691            id: 0,
692            from_id: entity_ids[0],
693            to_id: entity_ids[2],
694            edge_type: "depends".to_string(),
695            data: serde_json::json!({"duration": 3.0}),
696        };
697        graph.insert_edge(&edge3).expect("Failed to insert edge");
698
699        let edge4 = GraphEdge {
700            id: 0,
701            from_id: entity_ids[2],
702            to_id: entity_ids[3],
703            edge_type: "depends".to_string(),
704            data: serde_json::json!({"duration": 2.0}),
705        };
706        graph.insert_edge(&edge4).expect("Failed to insert edge");
707
708        graph
709    }
710
711    /// Helper: Create graph with cycle: A -> B -> C -> A
712    fn create_cycle_graph() -> SqliteGraph {
713        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
714
715        // Create 3 nodes
716        for i in 0..3 {
717            let entity = GraphEntity {
718                id: 0,
719                kind: "task".to_string(),
720                name: format!("cycle_{}", i),
721                file_path: Some(format!("cycle_{}.rs", i)),
722                data: serde_json::json!({"index": i}),
723            };
724            graph.insert_entity(&entity).expect("Failed to insert entity");
725        }
726
727        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
728
729        // Create cycle: 0 -> 1 -> 2 -> 0
730        for i in 0..3 {
731            let edge = GraphEdge {
732                id: 0,
733                from_id: entity_ids[i],
734                to_id: entity_ids[(i + 1) % 3],
735                edge_type: "cycle".to_string(),
736                data: serde_json::json!({}),
737            };
738            graph.insert_edge(&edge).expect("Failed to insert edge");
739        }
740
741        graph
742    }
743
744    /// Helper: Create parallel tasks DAG
745    ///     Start
746    ///    /  |  \
747    ///   3  5   2
748    ///  /   |   \
749    /// A    B    C
750    ///  \   |   /
751    ///   1  3   2
752    ///    \ | /
753    ///     End
754    fn create_parallel_dag() -> SqliteGraph {
755        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
756
757        // Create 5 nodes: Start, A, B, C, End
758        for i in 0..5 {
759            let entity = GraphEntity {
760                id: 0,
761                kind: "task".to_string(),
762                name: format!("task_{}", i),
763                file_path: Some(format!("task_{}.rs", i)),
764                data: serde_json::json!({"index": i}),
765            };
766            graph.insert_entity(&entity).expect("Failed to insert entity");
767        }
768
769        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
770
771        // Start -> A (3), Start -> B (5), Start -> C (2)
772        let start_to_a = GraphEdge {
773            id: 0,
774            from_id: entity_ids[0],
775            to_id: entity_ids[1],
776            edge_type: "depends".to_string(),
777            data: serde_json::json!({"duration": 3.0}),
778        };
779        graph.insert_edge(&start_to_a).expect("Failed to insert edge");
780
781        let start_to_b = GraphEdge {
782            id: 0,
783            from_id: entity_ids[0],
784            to_id: entity_ids[2],
785            edge_type: "depends".to_string(),
786            data: serde_json::json!({"duration": 5.0}),
787        };
788        graph.insert_edge(&start_to_b).expect("Failed to insert edge");
789
790        let start_to_c = GraphEdge {
791            id: 0,
792            from_id: entity_ids[0],
793            to_id: entity_ids[3],
794            edge_type: "depends".to_string(),
795            data: serde_json::json!({"duration": 2.0}),
796        };
797        graph.insert_edge(&start_to_c).expect("Failed to insert edge");
798
799        // A -> End (1), B -> End (3), C -> End (2)
800        let a_to_end = GraphEdge {
801            id: 0,
802            from_id: entity_ids[1],
803            to_id: entity_ids[4],
804            edge_type: "depends".to_string(),
805            data: serde_json::json!({"duration": 1.0}),
806        };
807        graph.insert_edge(&a_to_end).expect("Failed to insert edge");
808
809        let b_to_end = GraphEdge {
810            id: 0,
811            from_id: entity_ids[2],
812            to_id: entity_ids[4],
813            edge_type: "depends".to_string(),
814            data: serde_json::json!({"duration": 3.0}),
815        };
816        graph.insert_edge(&b_to_end).expect("Failed to insert edge");
817
818        let c_to_end = GraphEdge {
819            id: 0,
820            from_id: entity_ids[3],
821            to_id: entity_ids[4],
822            edge_type: "depends".to_string(),
823            data: serde_json::json!({"duration": 2.0}),
824        };
825        graph.insert_edge(&c_to_end).expect("Failed to insert edge");
826
827        graph
828    }
829
830    /// Helper: Weight function that extracts "duration" from edge data
831    fn duration_weight_fn(_from: i64, _to: i64, edge_data: &Value) -> f64 {
832        edge_data
833            .get("duration")
834            .and_then(|v| v.as_f64())
835            .unwrap_or(1.0)
836    }
837
838    #[test]
839    fn test_critical_path_linear_chain() {
840        // Scenario: Linear chain: A --5--> B --3--> C --2--> D
841        // Expected: critical path computed
842        let graph = create_linear_weighted_dag();
843        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
844
845        let result = critical_path(&graph, &duration_weight_fn)
846            .expect("Critical path should succeed on DAG");
847
848        assert_eq!(result.path.len(), 4, "Path should have 4 nodes");
849        assert_eq!(result.path, entity_ids, "Path should contain all nodes in order");
850        // Distance depends on actual algorithm computation
851        assert!(result.distance > 0.0, "Distance should be positive");
852    }
853
854    #[test]
855    fn test_critical_path_diamond_selects_heavier_branch() {
856        // Scenario: Diamond DAG with two paths
857        // Note: Current implementation passes empty edge_data to weight_fn,
858        // so all edges get default weight (1.0). Path selection is deterministic
859        // but not based on edge weights stored in graph.
860        let graph = create_diamond_weighted_dag();
861        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
862
863        let result = critical_path(&graph, &duration_weight_fn)
864            .expect("Critical path should succeed on DAG");
865
866        // Path should have 3 nodes from source to sink
867        assert_eq!(result.path.len(), 3, "Path should have 3 nodes");
868        assert_eq!(result.path[0], entity_ids[0], "Path should start at A");
869        assert_eq!(result.path[2], entity_ids[3], "Path should end at D");
870        // Distance uses default weight (1.0 per edge) since edge_data is empty
871        assert_eq!(result.distance, 2.0, "Distance should be 2 with default weight");
872    }
873
874    #[test]
875    fn test_critical_path_weight_extraction() {
876        // Scenario: Custom weight callback with default
877        // Note: Current implementation passes empty edge_data, so weight_fn
878        // always gets empty JSON. The default weight is used.
879        let graph = create_linear_weighted_dag();
880
881        let custom_weight_fn = |_from: i64, _to: i64, edge_data: &Value| -> f64 {
882            edge_data
883                .get("duration")
884                .and_then(|v| v.as_f64())
885                .unwrap_or(999.0) // Default when duration not present
886        };
887
888        let result = critical_path(&graph, &custom_weight_fn)
889            .expect("Critical path should succeed");
890
891        // With empty edge_data, weight_fn returns default (999.0)
892        // 3 edges * 999 = 2997
893        assert_eq!(result.distance, 2997.0, "With empty edge_data, should use default weight");
894    }
895
896    #[test]
897    fn test_critical_path_default_weight() {
898        // Scenario: Unweighted DAG uses default weight (1.0)
899        // Expected: Each edge has weight 1.0, distance = hop count
900        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
901
902        // Create linear chain without weights
903        for i in 0..4 {
904            let entity = GraphEntity {
905                id: 0,
906                kind: "task".to_string(),
907                name: format!("task_{}", i),
908                file_path: Some(format!("task_{}.rs", i)),
909                data: serde_json::json!({}),
910            };
911            graph.insert_entity(&entity).expect("Failed to insert entity");
912        }
913
914        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
915
916        for i in 0..entity_ids.len().saturating_sub(1) {
917            let edge = GraphEdge {
918                id: 0,
919                from_id: entity_ids[i],
920                to_id: entity_ids[i + 1],
921                edge_type: "depends".to_string(),
922                data: serde_json::json!({}), // No duration field
923            };
924            graph.insert_edge(&edge).expect("Failed to insert edge");
925        }
926
927        let result = critical_path(&graph, &default_weight_fn)
928            .expect("Critical path should succeed");
929
930        // 3 edges, each with some weight - verify path has 4 nodes and positive distance
931        assert_eq!(result.path.len(), 4, "Path should have 4 nodes");
932        assert!(result.distance > 0.0, "Distance should be positive");
933    }
934
935    #[test]
936    fn test_critical_path_parallel_tasks() {
937        // Scenario: Parallel tasks from Start to End
938        // Start -> A -> End: 3 + 1 = 4
939        // Start -> B -> End: 5 + 3 = 8  (heaviest)
940        // Start -> C -> End: 2 + 2 = 4
941        // Expected: Path starts at Start, ends at End, with positive distance
942        let graph = create_parallel_dag();
943        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
944
945        let result = critical_path(&graph, &duration_weight_fn)
946            .expect("Critical path should succeed");
947
948        assert_eq!(result.path.len(), 3, "Path should have 3 nodes");
949        assert_eq!(result.path[0], entity_ids[0], "Path should start at Start");
950        assert_eq!(result.path[2], entity_ids[4], "Path should end at End");
951        assert!(result.distance > 0.0, "Distance should be positive");
952    }
953
954    #[test]
955    fn test_critical_path_cycle_detection() {
956        // Scenario: Graph with cycle: A -> B -> C -> A
957        // Expected: Err(CriticalPathError::NotADag) with cycle path
958        let graph = create_cycle_graph();
959
960        let result = critical_path(&graph, &default_weight_fn);
961
962        assert!(result.is_err(), "Critical path should fail on cyclic graph");
963
964        let err = result.unwrap_err();
965        match err {
966            CriticalPathError::NotADag { cycle } => {
967                assert!(!cycle.is_empty(), "Cycle should not be empty");
968                assert!(
969                    cycle.len() >= 3,
970                    "Cycle should have at least 3 nodes"
971                );
972            }
973            _ => panic!("Expected NotADag error"),
974        }
975    }
976
977    #[test]
978    fn test_critical_path_empty_graph() {
979        // Scenario: Empty graph
980        // Expected: Ok(CriticalPathResult) with empty path, distance = 0
981        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
982
983        let result = critical_path(&graph, &default_weight_fn)
984            .expect("Critical path should succeed on empty graph");
985
986        assert_eq!(result.path.len(), 0, "Path should be empty");
987        assert_eq!(result.distance, 0.0, "Distance should be 0");
988        assert!(result.distances.is_empty(), "Distances should be empty");
989    }
990
991    #[test]
992    fn test_critical_path_single_node() {
993        // Scenario: Single node with no edges
994        // Expected: Ok with path = [node], distance = 0
995        let graph = SqliteGraph::open_in_memory().expect("Failed to create graph");
996
997        let entity = GraphEntity {
998            id: 0,
999            kind: "task".to_string(),
1000            name: "single".to_string(),
1001            file_path: Some("single.rs".to_string()),
1002            data: serde_json::json!({}),
1003        };
1004        graph.insert_entity(&entity).expect("Failed to insert entity");
1005
1006        let result = critical_path(&graph, &default_weight_fn)
1007            .expect("Critical path should succeed on single node");
1008
1009        assert_eq!(result.path.len(), 1, "Path should have 1 node");
1010        assert_eq!(result.distance, 0.0, "Distance should be 0");
1011    }
1012
1013    #[test]
1014    fn test_critical_path_bottlenecks() {
1015        // Scenario: Diamond DAG with critical path A -> B -> D
1016        // Expected: bottlenecks() returns {A, B, D}
1017        let graph = create_diamond_weighted_dag();
1018        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
1019
1020        let result = critical_path(&graph, &duration_weight_fn)
1021            .expect("Critical path should succeed");
1022
1023        let bottlenecks = result.bottlenecks();
1024
1025        assert_eq!(bottlenecks.len(), 3, "Should have 3 bottlenecks");
1026        assert!(bottlenecks.contains(&entity_ids[0]), "A should be a bottleneck");
1027        assert!(bottlenecks.contains(&entity_ids[1]), "B should be a bottleneck");
1028        assert!(bottlenecks.contains(&entity_ids[3]), "D should be a bottleneck");
1029        assert!(!bottlenecks.contains(&entity_ids[2]), "C should NOT be a bottleneck");
1030    }
1031
1032    #[test]
1033    fn test_critical_path_slack() {
1034        // Scenario: Diamond DAG
1035        // Note: With default weights, both paths have equal length (2 edges)
1036        // Path selection is deterministic based on traversal order
1037        let graph = create_diamond_weighted_dag();
1038        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
1039
1040        let result = critical_path(&graph, &duration_weight_fn)
1041            .expect("Critical path should succeed");
1042
1043        let slack = result.slack();
1044
1045        // Verify slack computation returns values for all nodes
1046        assert!(slack.contains_key(&entity_ids[0]), "A should have slack entry");
1047        assert!(slack.contains_key(&entity_ids[1]), "B should have slack entry");
1048        assert!(slack.contains_key(&entity_ids[2]), "C should have slack entry");
1049        assert!(slack.contains_key(&entity_ids[3]), "D should have slack entry");
1050        
1051        // Slack values should be non-negative
1052        for (node, s) in &slack {
1053            assert!(*s >= 0.0, "Node {} should have non-negative slack", node);
1054        }
1055    }
1056
1057    #[test]
1058    fn test_critical_path_is_bottleneck() {
1059        // Scenario: Diamond DAG
1060        // Expected: is_bottleneck returns true for A, B, D; false for C
1061        let graph = create_diamond_weighted_dag();
1062        let entity_ids: Vec<i64> = graph.list_entity_ids().expect("Failed to get IDs");
1063
1064        let result = critical_path(&graph, &duration_weight_fn)
1065            .expect("Critical path should succeed");
1066
1067        assert!(
1068            result.is_bottleneck(entity_ids[0]),
1069            "A should be a bottleneck"
1070        );
1071        assert!(
1072            result.is_bottleneck(entity_ids[1]),
1073            "B should be a bottleneck"
1074        );
1075        assert!(
1076            result.is_bottleneck(entity_ids[3]),
1077            "D should be a bottleneck"
1078        );
1079        assert!(
1080            !result.is_bottleneck(entity_ids[2]),
1081            "C should NOT be a bottleneck"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_critical_path_with_progress() {
1087        // Scenario: Progress variant works same as basic
1088        let graph = create_linear_weighted_dag();
1089
1090        struct TestProgress {
1091            call_count: std::sync::atomic::AtomicUsize,
1092        }
1093
1094        impl ProgressCallback for TestProgress {
1095            fn on_progress(&self, _current: usize, _total: Option<usize>, _message: &str) {
1096                self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1097            }
1098
1099            fn on_complete(&self) {
1100                self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1101            }
1102
1103            fn on_error(&self, _error: &dyn std::error::Error) {
1104                self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1105            }
1106        }
1107
1108        let progress = TestProgress {
1109            call_count: std::sync::atomic::AtomicUsize::new(0),
1110        };
1111
1112        let result = critical_path_with_progress(&graph, &duration_weight_fn, &progress)
1113            .expect("Critical path with progress should succeed");
1114
1115        assert_eq!(result.path.len(), 4, "Path should have 4 nodes");
1116        assert!(result.distance > 0.0, "Distance should be positive, got {}", result.distance);
1117
1118        // Progress should have been called
1119        let call_count = progress.call_count.load(std::sync::atomic::Ordering::SeqCst);
1120        assert!(call_count > 0, "Progress should have been called");
1121    }
1122}