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}