Skip to main content

cpm_planner/
algorithm.rs

1//! CPM Algorithm Implementation
2//!
3//! Implements the Critical Path Method (CPM) algorithm:
4//! 1. Forward pass: Calculate earliest start/finish times (ES/EF)
5//! 2. Backward pass: Calculate latest start/finish times (LS/LF)
6//! 3. Float calculation: slack = LS - ES
7//! 4. Critical path: Tasks where float = 0
8//! 5. Batch identification: Group tasks by earliest start time
9//! 6. Bottleneck analysis: Identify tasks that block the most work
10
11use crate::task::{Bottleneck, CriticalPathResult, Task, TaskBatch};
12use std::collections::{HashMap, HashSet, VecDeque};
13
14/// CPM Algorithm calculator
15pub struct CpmAlgorithm;
16
17impl CpmAlgorithm {
18    /// Calculate critical path and all related metrics
19    ///
20    /// # Arguments
21    /// * `tasks` - Mutable slice of tasks to analyze
22    ///
23    /// # Returns
24    /// `CriticalPathResult` with all calculated metrics
25    pub fn calculate(tasks: &mut [Task]) -> CriticalPathResult {
26        if tasks.is_empty() {
27            return CriticalPathResult::default();
28        }
29
30        // Build dependency graphs
31        let (successors, predecessors) = Self::build_dependency_graphs(tasks);
32
33        // Forward pass: calculate ES/EF. This is the authoritative cycle
34        // detector: an id here is a task whose dependencies could not be
35        // topologically ordered.
36        let mut unscheduled = Self::forward_pass(tasks, &predecessors);
37
38        // Backward pass: calculate LS/LF. It returns any task whose
39        // latest-finish never relaxed off the sentinel. In a well-formed
40        // (acyclic) graph that set is always empty, so the only way it is
41        // non-empty is a cycle the forward pass already flagged.
42        //
43        // When the forward pass already identified the unschedulable set we
44        // treat it as authoritative and do NOT widen it with the backward
45        // signal: backward also flags acyclic nodes that merely sit upstream
46        // of a cycle (their LF can't be finalised), and those were correctly
47        // scheduled by the forward pass. We only fold the backward findings
48        // in as a defence-in-depth net for the "forward saw nothing wrong but
49        // a task still never relaxed" case, which should not occur.
50        let backward_unscheduled = Self::backward_pass(tasks, &successors);
51        if unscheduled.is_empty() {
52            unscheduled = backward_unscheduled;
53        }
54        unscheduled.sort_unstable();
55        unscheduled.dedup();
56
57        // Calculate float and identify critical path
58        Self::calculate_float(tasks);
59
60        // Identify parallel batches
61        let batches = Self::identify_parallel_batches(tasks);
62
63        // Identify bottlenecks
64        let bottlenecks = Self::identify_bottlenecks(tasks, &successors);
65
66        // Build result
67        Self::build_result(tasks, batches, bottlenecks, unscheduled)
68    }
69
70    /// Build forward (successors) and reverse (predecessors) dependency graphs
71    fn build_dependency_graphs(
72        tasks: &[Task],
73    ) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
74        let mut successors: HashMap<String, Vec<String>> = HashMap::new();
75        let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
76
77        // Initialize empty lists for all tasks
78        for task in tasks {
79            successors.entry(task.id.clone()).or_default();
80            predecessors.entry(task.id.clone()).or_default();
81        }
82
83        // Build the graphs from dependencies
84        for task in tasks {
85            for dep in &task.dependencies {
86                // task depends on dep, so:
87                // dep -> task (dep is predecessor of task)
88                // task is successor of dep
89                successors
90                    .entry(dep.clone())
91                    .or_default()
92                    .push(task.id.clone());
93                predecessors
94                    .entry(task.id.clone())
95                    .or_default()
96                    .push(dep.clone());
97            }
98        }
99
100        (successors, predecessors)
101    }
102
103    /// Forward pass: Calculate earliest start (ES) and earliest finish (EF)
104    ///
105    /// ES = max(EF of all predecessors), or 0 if no predecessors
106    /// EF = ES + effort
107    ///
108    /// Returns the ids of any tasks that could not be scheduled because
109    /// they (or their predecessors) sit on a dependency cycle. An empty
110    /// return means the whole graph was schedulable.
111    fn forward_pass(
112        tasks: &mut [Task],
113        _predecessors: &HashMap<String, Vec<String>>,
114    ) -> Vec<String> {
115        let task_count = tasks.len();
116        let task_map: HashMap<String, usize> = tasks
117            .iter()
118            .enumerate()
119            .map(|(i, t)| (t.id.clone(), i))
120            .collect();
121
122        // Use Kahn's algorithm (topological sort) for forward pass
123        let mut in_degree: HashMap<String, usize> = HashMap::new();
124        for task in tasks.iter() {
125            in_degree.insert(task.id.clone(), task.dependencies.len());
126        }
127
128        // Start with tasks that have no dependencies
129        let mut queue: VecDeque<String> = tasks
130            .iter()
131            .filter(|t| t.dependencies.is_empty())
132            .map(|t| t.id.clone())
133            .collect();
134
135        // Initialize ES/EF for starting tasks
136        for task in tasks.iter_mut() {
137            if task.dependencies.is_empty() {
138                task.earliest_start = 0.0;
139                task.earliest_finish = task.effort_hours;
140            }
141        }
142
143        // Track which ids actually drained out of the topo queue. Any task
144        // that never reaches in-degree 0 sits on (or downstream of) a cycle
145        // and keeps its default ES/EF — i.e. it was not scheduled.
146        let mut scheduled: HashSet<String> = HashSet::with_capacity(task_count);
147        let mut processed = 0;
148        while let Some(current_id) = queue.pop_front() {
149            processed += 1;
150            scheduled.insert(current_id.clone());
151
152            // Get current task's EF. The queue only ever carries ids that
153            // came from `tasks` (initialized from `tasks.iter()` and pushed
154            // from successor walks), and `task_map` was populated from the
155            // same iterator above. The `None` branch is unreachable; assert
156            // it loudly so a future refactor that breaks the invariant
157            // doesn't degrade into silent skipping.
158            let current_ef = match task_map.get(&current_id) {
159                Some(&idx) => tasks[idx].earliest_finish,
160                None => unreachable!(
161                    "task_map missing id {current_id} that was queued from the same tasks slice"
162                ),
163            };
164
165            // Find successors by scanning all tasks
166            for task in tasks.iter_mut() {
167                if task.dependencies.contains(&current_id) {
168                    // Update ES if this predecessor has later EF
169                    if current_ef > task.earliest_start {
170                        task.earliest_start = current_ef;
171                        task.earliest_finish = task.earliest_start + task.effort_hours;
172                    }
173
174                    // Decrement in-degree and add to queue if ready
175                    if let Some(deg) = in_degree.get_mut(&task.id) {
176                        *deg = deg.saturating_sub(1);
177                        if *deg == 0 {
178                            queue.push_back(task.id.clone());
179                        }
180                    }
181                }
182            }
183        }
184
185        // Handle case where not all tasks were processed (cycle in deps).
186        // Collect the unschedulable ids so callers can detect and reject
187        // the otherwise confidently-wrong result instead of only seeing a
188        // log line.
189        if processed < task_count {
190            let mut unscheduled: Vec<String> = tasks
191                .iter()
192                .filter(|t| !scheduled.contains(&t.id))
193                .map(|t| t.id.clone())
194                .collect();
195            unscheduled.sort_unstable();
196            tracing::warn!(
197                unscheduled = unscheduled.len(),
198                "CPM forward pass could not schedule all tasks (possible dependency cycle)"
199            );
200            return unscheduled;
201        }
202
203        Vec::new()
204    }
205
206    /// Backward pass: Calculate latest start (LS) and latest finish (LF)
207    ///
208    /// LF = min(LS of all successors), or `project_end` if no successors
209    /// LS = LF - effort
210    ///
211    /// Returns the ids of any tasks whose `latest_finish` never relaxed off
212    /// the `f32::MAX` sentinel. In a well-formed graph this is empty; a
213    /// non-empty return means those tasks are unschedulable (the same cycle
214    /// signal the forward pass surfaces) and the result must not be trusted.
215    fn backward_pass(tasks: &mut [Task], successors: &HashMap<String, Vec<String>>) -> Vec<String> {
216        // Find project duration (max EF)
217        let project_duration = tasks
218            .iter()
219            .map(|t| t.earliest_finish)
220            .fold(0.0_f32, f32::max);
221
222        // Create a map for quick lookup
223        let task_map: HashMap<String, usize> = tasks
224            .iter()
225            .enumerate()
226            .map(|(i, t)| (t.id.clone(), i))
227            .collect();
228
229        // Initialize LF/LS for ending tasks (no successors)
230        for task in tasks.iter_mut() {
231            let has_successors = successors.get(&task.id).is_some_and(|s| !s.is_empty());
232            if has_successors {
233                // Initialize to max values for later minimization
234                task.latest_finish = f32::MAX;
235                task.latest_start = f32::MAX;
236            } else {
237                task.latest_finish = project_duration;
238                task.latest_start = project_duration - task.effort_hours;
239            }
240        }
241
242        // Reverse topological order processing
243        // We iterate until no changes (simpler than proper reverse topo sort)
244        let mut changed = true;
245        let mut iterations = 0;
246        let max_iterations = tasks.len() * 2;
247
248        while changed && iterations < max_iterations {
249            changed = false;
250            iterations += 1;
251
252            for i in 0..tasks.len() {
253                let task_id = tasks[i].id.clone();
254                let task_effort = tasks[i].effort_hours;
255
256                // Find minimum LS of all successors
257                if let Some(succ_ids) = successors.get(&task_id) {
258                    if !succ_ids.is_empty() {
259                        let min_succ_ls = succ_ids
260                            .iter()
261                            .filter_map(|sid| task_map.get(sid).map(|&idx| tasks[idx].latest_start))
262                            .filter(|&ls| ls < f32::MAX)
263                            .fold(f32::MAX, f32::min);
264
265                        if min_succ_ls < f32::MAX && min_succ_ls < tasks[i].latest_finish {
266                            tasks[i].latest_finish = min_succ_ls;
267                            tasks[i].latest_start = min_succ_ls - task_effort;
268                            changed = true;
269                        }
270                    }
271                }
272            }
273        }
274
275        // Any task still pinned at the MAX sentinel never had its LF/LS
276        // relaxed — in a well-formed DAG that cannot happen, so treat it as
277        // the same unschedulable signal the forward pass raises rather than
278        // silently clamping to project_duration and emitting a plausible-
279        // looking (but wrong) plan. We still clamp the numeric fields so the
280        // struct holds finite values, but the returned ids let callers
281        // reject the result.
282        let mut unscheduled: Vec<String> = Vec::new();
283        for task in tasks.iter_mut() {
284            if task.latest_finish >= f32::MAX - 1.0 {
285                task.latest_finish = project_duration;
286                task.latest_start = project_duration - task.effort_hours;
287                unscheduled.push(task.id.clone());
288            }
289        }
290        unscheduled.sort_unstable();
291        unscheduled
292    }
293
294    /// Calculate float (slack) for each task and mark critical path
295    fn calculate_float(tasks: &mut [Task]) {
296        for task in tasks.iter_mut() {
297            task.float = task.latest_start - task.earliest_start;
298            // Critical if float is essentially zero (within tolerance)
299            task.is_critical = task.float.abs() < 0.001;
300        }
301    }
302
303    /// Identify parallel batches by grouping tasks with same earliest start
304    fn identify_parallel_batches(tasks: &[Task]) -> Vec<TaskBatch> {
305        // Group tasks by earliest start time (rounded to avoid float issues)
306        let mut batches_map: HashMap<i32, Vec<&Task>> = HashMap::new();
307
308        for task in tasks {
309            // Round to nearest 0.1 hour for batching
310            let es_key = (task.earliest_start * 10.0).round() as i32;
311            batches_map.entry(es_key).or_default().push(task);
312        }
313
314        // Convert to sorted Vec<TaskBatch>
315        let mut sorted_keys: Vec<i32> = batches_map.keys().copied().collect();
316        sorted_keys.sort_unstable();
317
318        let mut batches = Vec::new();
319        let mut batch_num = 0;
320
321        for es_key in sorted_keys {
322            if let Some(batch_tasks) = batches_map.get(&es_key) {
323                let total_effort: f32 = batch_tasks.iter().map(|t| t.effort_hours).sum();
324                let duration = batch_tasks
325                    .iter()
326                    .map(|t| t.effort_hours)
327                    .fold(0.0_f32, f32::max);
328                let start_time = es_key as f32 / 10.0;
329
330                batches.push(TaskBatch {
331                    id: format!("Batch-{batch_num}"),
332                    tasks: batch_tasks.iter().map(|t| t.id.clone()).collect(),
333                    total_effort_hours: total_effort,
334                    duration_hours: duration,
335                    start_time,
336                });
337                batch_num += 1;
338            }
339        }
340
341        batches
342    }
343
344    /// Identify bottleneck tasks based on transitive impact
345    fn identify_bottlenecks(
346        tasks: &[Task],
347        successors: &HashMap<String, Vec<String>>,
348    ) -> Vec<Bottleneck> {
349        let task_map: HashMap<&str, &Task> = tasks.iter().map(|t| (t.id.as_str(), t)).collect();
350
351        let mut bottlenecks = Vec::new();
352
353        for task in tasks {
354            // Count transitively blocked tasks
355            let blocked_ids = Self::get_transitive_successors(&task.id, successors);
356            let blocks_count = blocked_ids.len();
357
358            if blocks_count == 0 {
359                continue;
360            }
361
362            // Calculate total blocked hours
363            let blocked_hours: f32 = blocked_ids
364                .iter()
365                .filter_map(|id| task_map.get(id.as_str()).map(|t| t.effort_hours))
366                .sum();
367
368            // Calculate ROI
369            let roi = if task.effort_hours > 0.0 {
370                blocked_hours / task.effort_hours
371            } else {
372                0.0
373            };
374
375            bottlenecks.push(Bottleneck {
376                task_id: task.id.clone(),
377                task_name: task.name.clone(),
378                blocks_count,
379                blocked_hours,
380                roi,
381                effort_hours: task.effort_hours,
382            });
383        }
384
385        // Sort by ROI descending
386        bottlenecks.sort_by(|a, b| {
387            b.roi
388                .partial_cmp(&a.roi)
389                .unwrap_or(std::cmp::Ordering::Equal)
390        });
391
392        bottlenecks
393    }
394
395    /// Get all tasks transitively blocked by the given task
396    fn get_transitive_successors(
397        task_id: &str,
398        successors: &HashMap<String, Vec<String>>,
399    ) -> HashSet<String> {
400        let mut visited = HashSet::new();
401        let mut stack = vec![task_id.to_string()];
402
403        while let Some(current) = stack.pop() {
404            if let Some(succs) = successors.get(&current) {
405                for succ in succs {
406                    if !visited.contains(succ) {
407                        visited.insert(succ.clone());
408                        stack.push(succ.clone());
409                    }
410                }
411            }
412        }
413
414        visited
415    }
416
417    /// Build the final result
418    fn build_result(
419        tasks: &[Task],
420        batches: Vec<TaskBatch>,
421        bottlenecks: Vec<Bottleneck>,
422        unscheduled: Vec<String>,
423    ) -> CriticalPathResult {
424        // Extract critical path (sorted by ES)
425        let mut critical_tasks: Vec<&Task> = tasks.iter().filter(|t| t.is_critical).collect();
426        critical_tasks.sort_by(|a, b| {
427            a.earliest_start
428                .partial_cmp(&b.earliest_start)
429                .unwrap_or(std::cmp::Ordering::Equal)
430        });
431
432        let critical_path: Vec<String> = critical_tasks.iter().map(|t| t.id.clone()).collect();
433
434        let critical_path_duration: f32 = critical_tasks.iter().map(|t| t.effort_hours).sum();
435
436        let total_duration_sequential: f32 = tasks.iter().map(|t| t.effort_hours).sum();
437
438        // Parallel duration is the sum of batch durations
439        let optimal_duration_parallel: f32 = batches.iter().map(|b| b.duration_hours).sum();
440
441        let speedup_factor = if optimal_duration_parallel > 0.0 {
442            total_duration_sequential / optimal_duration_parallel
443        } else {
444            1.0
445        };
446
447        CriticalPathResult {
448            total_tasks: tasks.len(),
449            critical_path,
450            critical_path_duration,
451            total_duration_sequential,
452            optimal_duration_parallel,
453            speedup_factor,
454            parallelizable_batches: batches,
455            bottlenecks,
456            tasks: tasks.to_vec(),
457            unscheduled,
458        }
459    }
460}
461
462#[cfg(test)]
463#[allow(clippy::float_cmp)]
464mod tests {
465    use super::*;
466
467    fn make_task(id: &str, effort: f32, deps: Vec<&str>) -> Task {
468        Task {
469            id: id.to_string(),
470            name: format!("Task {id}"),
471            effort_hours: effort,
472            dependencies: deps.into_iter().map(String::from).collect(),
473            ..Default::default()
474        }
475    }
476
477    #[test]
478    fn test_simple_chain() {
479        // A -> B -> C (linear dependency)
480        let mut tasks = vec![
481            make_task("A", 2.0, vec![]),
482            make_task("B", 3.0, vec!["A"]),
483            make_task("C", 1.0, vec!["B"]),
484        ];
485
486        let result = CpmAlgorithm::calculate(&mut tasks);
487
488        // All tasks should be on critical path
489        assert_eq!(result.critical_path.len(), 3);
490        assert_eq!(result.critical_path_duration, 6.0);
491        // No parallelization possible
492        assert!((result.speedup_factor - 1.0).abs() < 0.01);
493    }
494
495    #[test]
496    fn test_parallel_tasks() {
497        // A -> B
498        // A -> C (B and C can run in parallel)
499        let mut tasks = vec![
500            make_task("A", 1.0, vec![]),
501            make_task("B", 2.0, vec!["A"]),
502            make_task("C", 3.0, vec!["A"]),
503        ];
504
505        let result = CpmAlgorithm::calculate(&mut tasks);
506
507        // Critical path: A -> C (1 + 3 = 4h)
508        assert_eq!(result.critical_path_duration, 4.0);
509        // Total sequential: 1 + 2 + 3 = 6h
510        assert_eq!(result.total_duration_sequential, 6.0);
511        // Speedup should be > 1
512        assert!(result.speedup_factor > 1.0);
513    }
514
515    #[test]
516    fn test_diamond_dependency() {
517        //     B
518        //   /   \
519        // A       D
520        //   \   /
521        //     C
522        let mut tasks = vec![
523            make_task("A", 1.0, vec![]),
524            make_task("B", 2.0, vec!["A"]),
525            make_task("C", 4.0, vec!["A"]),
526            make_task("D", 1.0, vec!["B", "C"]),
527        ];
528
529        let result = CpmAlgorithm::calculate(&mut tasks);
530
531        // Critical path: A -> C -> D (1 + 4 + 1 = 6h)
532        assert_eq!(result.critical_path_duration, 6.0);
533        // B should not be on critical path (has float)
534        assert!(!result.critical_path.contains(&"B".to_string()));
535    }
536
537    #[test]
538    fn test_bottleneck_identification() {
539        // A blocks B, C, D (high ROI)
540        let mut tasks = vec![
541            make_task("A", 1.0, vec![]),
542            make_task("B", 5.0, vec!["A"]),
543            make_task("C", 5.0, vec!["A"]),
544            make_task("D", 5.0, vec!["A"]),
545        ];
546
547        let result = CpmAlgorithm::calculate(&mut tasks);
548
549        // A should be identified as top bottleneck
550        assert!(!result.bottlenecks.is_empty());
551        assert_eq!(result.bottlenecks[0].task_id, "A");
552        // ROI = 15h blocked / 1h effort = 15
553        assert!(result.bottlenecks[0].roi >= 14.0);
554    }
555
556    #[test]
557    fn test_empty_tasks() {
558        let mut tasks: Vec<Task> = vec![];
559        let result = CpmAlgorithm::calculate(&mut tasks);
560        assert_eq!(result.total_tasks, 0);
561        assert!(result.critical_path.is_empty());
562    }
563
564    #[test]
565    fn test_single_task() {
566        let mut tasks = vec![make_task("A", 5.0, vec![])];
567        let result = CpmAlgorithm::calculate(&mut tasks);
568
569        assert_eq!(result.total_tasks, 1);
570        assert_eq!(result.critical_path_duration, 5.0);
571        assert_eq!(result.critical_path, vec!["A".to_string()]);
572    }
573
574    #[test]
575    fn test_cyclic_graph_is_reported_unscheduled() {
576        // A -> B -> C -> A forms a cycle: none of these can be scheduled.
577        let mut tasks = vec![
578            make_task("A", 1.0, vec!["C"]),
579            make_task("B", 1.0, vec!["A"]),
580            make_task("C", 1.0, vec!["B"]),
581        ];
582
583        let result = CpmAlgorithm::calculate(&mut tasks);
584
585        // The forward pass cannot drain the topo queue, so every member of
586        // the cycle must surface in `unscheduled` (sorted) rather than the
587        // result silently looking valid.
588        assert_eq!(
589            result.unscheduled,
590            vec!["A".to_string(), "B".to_string(), "C".to_string()]
591        );
592    }
593
594    #[test]
595    fn test_partial_cycle_reports_only_cycle_members() {
596        // ROOT is schedulable; X<->Y form a 2-cycle and are not.
597        let mut tasks = vec![
598            make_task("ROOT", 1.0, vec![]),
599            make_task("X", 1.0, vec!["ROOT", "Y"]),
600            make_task("Y", 1.0, vec!["X"]),
601        ];
602
603        let result = CpmAlgorithm::calculate(&mut tasks);
604
605        assert_eq!(result.unscheduled, vec!["X".to_string(), "Y".to_string()]);
606    }
607
608    #[test]
609    fn test_acyclic_graph_has_empty_unscheduled() {
610        let mut tasks = vec![make_task("A", 2.0, vec![]), make_task("B", 3.0, vec!["A"])];
611        let result = CpmAlgorithm::calculate(&mut tasks);
612        assert!(result.unscheduled.is_empty());
613    }
614
615    #[test]
616    fn test_batch_grouping() {
617        // Two independent tasks starting at same time
618        let mut tasks = vec![make_task("A", 2.0, vec![]), make_task("B", 3.0, vec![])];
619
620        let result = CpmAlgorithm::calculate(&mut tasks);
621
622        // Should be in same batch (start at time 0)
623        assert_eq!(result.parallelizable_batches.len(), 1);
624        assert_eq!(result.parallelizable_batches[0].tasks.len(), 2);
625        // Duration is max (3h), not sum (5h)
626        assert_eq!(result.parallelizable_batches[0].duration_hours, 3.0);
627        assert_eq!(result.parallelizable_batches[0].total_effort_hours, 5.0);
628    }
629}