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: `critical_path` is one longest prerequisite chain in
8//!    execution order; `critical_ids` lists every zero-float (critical) task.
9//! 5. Batch identification: Group tasks by earliest start time
10//! 6. Bottleneck analysis: Identify tasks that block the most work
11
12use crate::task::{Bottleneck, CriticalPathResult, Task, TaskBatch};
13use std::collections::{HashMap, HashSet, VecDeque};
14
15/// Version of the CPM kernel's output semantics. Bump whenever a change
16/// alters any field of [`CriticalPathResult`] for the same input, so stored
17/// plans are recomputed on open (see `plan_store`). Bump also when
18/// `EffortEstimator` defaults change, since stored plans are recomputed with
19/// the current estimator.
20///
21/// - v2: synthetic `__start__`/`__finish__` endpoints
22/// - v3: calendar `duration_hours` and per-edge `lag_hours`
23pub const CPM_VERSION: i64 = 3;
24
25const TIGHT_EPS: f32 = 1e-3;
26
27/// CPM Algorithm calculator
28pub struct CpmAlgorithm;
29
30impl CpmAlgorithm {
31    /// Calculate critical path and all related metrics
32    ///
33    /// # Arguments
34    /// * `tasks` - Mutable slice of tasks to analyze
35    ///
36    /// # Returns
37    /// `CriticalPathResult` with all calculated metrics
38    pub fn calculate(tasks: &mut [Task]) -> CriticalPathResult {
39        if tasks.is_empty() {
40            return CriticalPathResult::default();
41        }
42
43        // Duplicate ids make the id-keyed passes ill-defined (and the
44        // forward pass could requeue a shared id forever). Bail out with
45        // every task unscheduled so callers reject the result.
46        let mut ids: HashSet<&str> = HashSet::with_capacity(tasks.len());
47        if !tasks.iter().all(|t| ids.insert(t.id.as_str())) {
48            let mut unscheduled: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
49            unscheduled.sort_unstable();
50            unscheduled.dedup();
51            return CriticalPathResult {
52                total_tasks: tasks.len(),
53                tasks: tasks.to_vec(),
54                unscheduled,
55                ..CriticalPathResult::default()
56            };
57        }
58
59        // Build dependency graphs
60        let (successors, _predecessors) = Self::build_dependency_graphs(tasks);
61
62        // Forward pass: calculate ES/EF. This is the authoritative cycle
63        // detector: an id here is a task whose dependencies could not be
64        // topologically ordered.
65        let (mut unscheduled, order) = Self::forward_pass(tasks);
66
67        // Backward pass: calculate LS/LF. It returns any task whose
68        // latest-finish never relaxed off the sentinel. In a well-formed
69        // (acyclic) graph that set is always empty, so the only way it is
70        // non-empty is a cycle the forward pass already flagged.
71        //
72        // When the forward pass already identified the unschedulable set we
73        // treat it as authoritative and do NOT widen it with the backward
74        // signal: backward also flags acyclic nodes that merely sit upstream
75        // of a cycle (their LF can't be finalised), and those were correctly
76        // scheduled by the forward pass. We only fold the backward findings
77        // in as a defence-in-depth net for the "forward saw nothing wrong but
78        // a task still never relaxed" case, which should not occur.
79        let backward_unscheduled = Self::backward_pass(tasks, &successors, &order);
80        if unscheduled.is_empty() {
81            unscheduled = backward_unscheduled;
82        }
83        unscheduled.sort_unstable();
84        unscheduled.dedup();
85
86        // Calculate float and identify critical path
87        Self::calculate_float(tasks);
88
89        // Identify parallel batches
90        let batches = Self::identify_parallel_batches(tasks);
91
92        // Identify bottlenecks
93        let bottlenecks = Self::identify_bottlenecks(tasks);
94
95        // Build result
96        Self::build_result(tasks, batches, bottlenecks, unscheduled)
97    }
98
99    /// Build forward (successors) and reverse (predecessors) dependency graphs
100    fn build_dependency_graphs(
101        tasks: &[Task],
102    ) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
103        let mut successors: HashMap<String, Vec<String>> = HashMap::new();
104        let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
105
106        // Initialize empty lists for all tasks
107        for task in tasks {
108            successors.entry(task.id.clone()).or_default();
109            predecessors.entry(task.id.clone()).or_default();
110        }
111
112        // Build the graphs from dependencies
113        for task in tasks {
114            for dep in &task.dependencies {
115                // task depends on dep, so:
116                // dep -> task (dep is predecessor of task)
117                // task is successor of dep
118                successors
119                    .entry(dep.clone())
120                    .or_default()
121                    .push(task.id.clone());
122                predecessors
123                    .entry(task.id.clone())
124                    .or_default()
125                    .push(dep.clone());
126            }
127        }
128
129        (successors, predecessors)
130    }
131
132    /// Forward pass: Calculate earliest start (ES) and earliest finish (EF)
133    ///
134    /// ES = max(EF of each predecessor + edge lag), or 0 if no predecessors
135    /// EF = ES + scheduled length (`duration_hours` when set, else effort)
136    ///
137    /// Returns the ids of any tasks that could not be scheduled because
138    /// they (or their predecessors) sit on a dependency cycle (an empty list
139    /// means the whole graph was schedulable), plus the task indices in the
140    /// order they were scheduled. Ids are unique here (`calculate` bails out
141    /// on duplicates first).
142    fn forward_pass(tasks: &mut [Task]) -> (Vec<String>, Vec<usize>) {
143        // Passes only ever raise ES, so clear stale values from earlier runs.
144        for task in tasks.iter_mut() {
145            task.earliest_start = 0.0;
146            task.earliest_finish = 0.0;
147        }
148        let task_count = tasks.len();
149        let ids: Vec<String> = tasks.iter().map(|t| t.id.clone()).collect();
150        let task_map: HashMap<&str, usize> = ids
151            .iter()
152            .enumerate()
153            .map(|(i, id)| (id.as_str(), i))
154            .collect();
155
156        // Dependents of each task, each listed once, in task order; and the
157        // number of distinct dependencies (unknown ids included, so a task
158        // depending on a missing id is never scheduled).
159        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); task_count];
160        let mut in_degree: Vec<usize> = Vec::with_capacity(task_count);
161        for (i, task) in tasks.iter().enumerate() {
162            let distinct: HashSet<&str> = task.dependencies.iter().map(String::as_str).collect();
163            in_degree.push(distinct.len());
164            for dep in distinct {
165                if let Some(&d) = task_map.get(dep) {
166                    dependents[d].push(i);
167                }
168            }
169        }
170        for list in &mut dependents {
171            list.sort_unstable();
172        }
173
174        // Kahn's algorithm, starting with tasks that have no dependencies.
175        let mut queue: VecDeque<usize> = (0..task_count)
176            .filter(|&i| tasks[i].dependencies.is_empty())
177            .collect();
178        for &i in &queue {
179            tasks[i].earliest_finish = tasks[i].effort_hours;
180        }
181
182        let mut order: Vec<usize> = Vec::with_capacity(task_count);
183        while let Some(current) = queue.pop_front() {
184            order.push(current);
185            let current_ef = tasks[current].earliest_finish;
186            for &i in &dependents[current] {
187                let task = &mut tasks[i];
188                // Update ES if this predecessor (plus edge lag) is later.
189                let lag = task
190                    .lag_by_dependency
191                    .get(&ids[current])
192                    .copied()
193                    .unwrap_or(0.0);
194                if current_ef + lag > task.earliest_start {
195                    task.earliest_start = current_ef + lag;
196                    task.earliest_finish = task.earliest_start + task.effort_hours;
197                }
198                in_degree[i] -= 1;
199                if in_degree[i] == 0 {
200                    // All predecessors processed: lock in the final EF.
201                    task.earliest_finish = task.earliest_start + task.effort_hours;
202                    queue.push_back(i);
203                }
204            }
205        }
206
207        // Tasks never drained from the queue sit on (or downstream of) a
208        // cycle, or depend on an unknown id: report them so callers reject
209        // the otherwise confidently-wrong result.
210        if order.len() < task_count {
211            let mut scheduled = vec![false; task_count];
212            for &i in &order {
213                scheduled[i] = true;
214            }
215            let mut unscheduled: Vec<String> = (0..task_count)
216                .filter(|&i| !scheduled[i])
217                .map(|i| tasks[i].id.clone())
218                .collect();
219            unscheduled.sort_unstable();
220            tracing::warn!(
221                unscheduled = unscheduled.len(),
222                "CPM forward pass could not schedule all tasks (possible dependency cycle)"
223            );
224            return (unscheduled, order);
225        }
226
227        (Vec::new(), order)
228    }
229
230    /// Backward pass: Calculate latest start (LS) and latest finish (LF)
231    ///
232    /// LF = min(LS of each successor - edge lag), or `project_end` if no
233    /// successors
234    /// LS = LF - scheduled length
235    ///
236    /// Returns the ids of any tasks whose `latest_finish` never relaxed off
237    /// the `f32::MAX` sentinel. In a well-formed graph this is empty; a
238    /// non-empty return means those tasks are unschedulable (the same cycle
239    /// signal the forward pass surfaces) and the result must not be trusted.
240    ///
241    /// Tasks are relaxed in reverse `order` (the forward pass's topological
242    /// order), then any task it never scheduled, so an acyclic graph settles
243    /// in one sweep plus a confirming one.
244    fn backward_pass(
245        tasks: &mut [Task],
246        successors: &HashMap<String, Vec<String>>,
247        order: &[usize],
248    ) -> Vec<String> {
249        // Find project duration (max EF)
250        let project_duration = tasks
251            .iter()
252            .map(|t| t.earliest_finish)
253            .fold(0.0_f32, f32::max);
254
255        // Create a map for quick lookup
256        let task_map: HashMap<String, usize> = tasks
257            .iter()
258            .enumerate()
259            .map(|(i, t)| (t.id.clone(), i))
260            .collect();
261
262        // Initialize LF/LS for ending tasks (no successors)
263        for task in tasks.iter_mut() {
264            let has_successors = successors.get(&task.id).is_some_and(|s| !s.is_empty());
265            if has_successors {
266                // Initialize to max values for later minimization
267                task.latest_finish = f32::MAX;
268                task.latest_start = f32::MAX;
269            } else {
270                task.latest_finish = project_duration;
271                task.latest_start = project_duration - task.effort_hours;
272            }
273        }
274
275        // Reverse topological order processing
276        // We iterate until no changes (simpler than proper reverse topo sort)
277        let mut visit: Vec<usize> = order.iter().rev().copied().collect();
278        if visit.len() < tasks.len() {
279            let mut in_order = vec![false; tasks.len()];
280            for &i in order {
281                in_order[i] = true;
282            }
283            visit.extend((0..tasks.len()).filter(|&i| !in_order[i]));
284        }
285        let mut changed = true;
286        let mut iterations = 0;
287        let max_iterations = tasks.len() * 2;
288
289        while changed && iterations < max_iterations {
290            changed = false;
291            iterations += 1;
292
293            for &i in &visit {
294                let task_id = tasks[i].id.clone();
295                let task_effort = tasks[i].effort_hours;
296
297                // Find minimum LS of all successors
298                if let Some(succ_ids) = successors.get(&task_id)
299                    && !succ_ids.is_empty()
300                {
301                    let min_succ_ls = succ_ids
302                        .iter()
303                        .filter_map(|sid| {
304                            task_map.get(sid).map(|&idx| {
305                                let lag = tasks[idx]
306                                    .lag_by_dependency
307                                    .get(&task_id)
308                                    .copied()
309                                    .unwrap_or(0.0);
310                                if tasks[idx].latest_start < f32::MAX {
311                                    tasks[idx].latest_start - lag
312                                } else {
313                                    f32::MAX
314                                }
315                            })
316                        })
317                        .filter(|&ls| ls < f32::MAX)
318                        .fold(f32::MAX, f32::min);
319
320                    if min_succ_ls < f32::MAX && min_succ_ls < tasks[i].latest_finish {
321                        tasks[i].latest_finish = min_succ_ls;
322                        tasks[i].latest_start = min_succ_ls - task_effort;
323                        changed = true;
324                    }
325                }
326            }
327        }
328
329        // Any task still pinned at the MAX sentinel never had its LF/LS
330        // relaxed — in a well-formed DAG that cannot happen, so treat it as
331        // the same unschedulable signal the forward pass raises rather than
332        // silently clamping to project_duration and emitting a plausible-
333        // looking (but wrong) plan. We still clamp the numeric fields so the
334        // struct holds finite values, but the returned ids let callers
335        // reject the result.
336        let mut unscheduled: Vec<String> = Vec::new();
337        for task in tasks.iter_mut() {
338            if task.latest_finish >= f32::MAX - 1.0 {
339                task.latest_finish = project_duration;
340                task.latest_start = project_duration - task.effort_hours;
341                unscheduled.push(task.id.clone());
342            }
343        }
344        unscheduled.sort_unstable();
345        unscheduled
346    }
347
348    /// Calculate float (slack) for each task and mark critical path
349    fn calculate_float(tasks: &mut [Task]) {
350        for task in tasks.iter_mut() {
351            task.float = task.latest_start - task.earliest_start;
352            // Critical if float is essentially zero (within tolerance)
353            task.is_critical = task.float.abs() < 0.001;
354        }
355    }
356
357    /// Identify parallel batches by grouping tasks with same earliest start
358    fn identify_parallel_batches(tasks: &[Task]) -> Vec<TaskBatch> {
359        // Group tasks by earliest start time (rounded to avoid float issues)
360        let mut batches_map: HashMap<i32, Vec<&Task>> = HashMap::new();
361
362        for task in tasks {
363            // Round to nearest 0.1 hour for batching
364            let es_key = (task.earliest_start * 10.0).round() as i32;
365            batches_map.entry(es_key).or_default().push(task);
366        }
367
368        // Convert to sorted Vec<TaskBatch>
369        let mut sorted_keys: Vec<i32> = batches_map.keys().copied().collect();
370        sorted_keys.sort_unstable();
371
372        let mut batches = Vec::new();
373        let mut batch_num = 0;
374
375        for es_key in sorted_keys {
376            if let Some(batch_tasks) = batches_map.get(&es_key) {
377                let total_effort: f32 = batch_tasks.iter().map(|t| t.effort_hours).sum();
378                let duration = batch_tasks
379                    .iter()
380                    .map(|t| t.effort_hours)
381                    .fold(0.0_f32, f32::max);
382                let start_time = es_key as f32 / 10.0;
383
384                batches.push(TaskBatch {
385                    id: format!("Batch-{batch_num}"),
386                    tasks: batch_tasks.iter().map(|t| t.id.clone()).collect(),
387                    total_effort_hours: total_effort,
388                    duration_hours: duration,
389                    start_time,
390                });
391                batch_num += 1;
392            }
393        }
394
395        batches
396    }
397
398    /// Identify bottleneck tasks based on transitive impact. Each task's
399    /// transitive successors are found by an index-based walk with a
400    /// visit stamp, so no per-task sets are allocated.
401    fn identify_bottlenecks(tasks: &[Task]) -> Vec<Bottleneck> {
402        let task_map: HashMap<&str, usize> = tasks
403            .iter()
404            .enumerate()
405            .map(|(i, t)| (t.id.as_str(), i))
406            .collect();
407        let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); tasks.len()];
408        for (i, task) in tasks.iter().enumerate() {
409            for dep in &task.dependencies {
410                if let Some(&d) = task_map.get(dep.as_str()) {
411                    dependents[d].push(i);
412                }
413            }
414        }
415
416        let mut bottlenecks = Vec::new();
417        let mut stamp: Vec<usize> = vec![usize::MAX; tasks.len()];
418        let mut stack: Vec<usize> = Vec::new();
419
420        for (root, task) in tasks.iter().enumerate() {
421            // Count transitively blocked tasks and their hours.
422            let mut blocks_count = 0;
423            let mut blocked_hours = 0.0_f32;
424            stack.clear();
425            stack.push(root);
426            while let Some(current) = stack.pop() {
427                for &succ in &dependents[current] {
428                    if stamp[succ] != root {
429                        stamp[succ] = root;
430                        blocks_count += 1;
431                        blocked_hours += tasks[succ].effort_hours;
432                        stack.push(succ);
433                    }
434                }
435            }
436
437            if blocks_count == 0 {
438                continue;
439            }
440
441            // Calculate ROI
442            let roi = if task.effort_hours > 0.0 {
443                blocked_hours / task.effort_hours
444            } else {
445                0.0
446            };
447
448            bottlenecks.push(Bottleneck {
449                task_id: task.id.clone(),
450                task_name: task.name.clone(),
451                blocks_count,
452                blocked_hours,
453                roi,
454                effort_hours: task.effort_hours,
455            });
456        }
457
458        // Sort by ROI descending
459        bottlenecks.sort_by(|a, b| {
460            b.roi
461                .partial_cmp(&a.roi)
462                .unwrap_or(std::cmp::Ordering::Equal)
463        });
464
465        bottlenecks
466    }
467
468    /// Trace one longest chain backwards from the task with the maximum
469    /// earliest finish, following predecessors whose EF equals the
470    /// successor's ES. Sink ties resolve to the later earliest start, then the
471    /// smallest id; predecessor ties resolve to the smallest id. Bounded by the
472    /// task count so malformed (cyclic) input cannot loop.
473    fn trace_critical_path(tasks: &[Task]) -> Vec<String> {
474        let Some(sink) = tasks.iter().max_by(|a, b| {
475            a.earliest_finish
476                .partial_cmp(&b.earliest_finish)
477                .unwrap_or(std::cmp::Ordering::Equal)
478                .then_with(|| {
479                    a.earliest_start
480                        .partial_cmp(&b.earliest_start)
481                        .unwrap_or(std::cmp::Ordering::Equal)
482                })
483                .then_with(|| b.id.cmp(&a.id))
484        }) else {
485            return Vec::new();
486        };
487        Self::trace_back(tasks, sink)
488    }
489
490    /// Trace the longest chain ending at the task `end_id` (same tie rules as
491    /// [`Self::trace_critical_path`]), restricted to that task's ancestors.
492    /// Empty when `end_id` is unknown.
493    pub(crate) fn trace_path_to(tasks: &[Task], end_id: &str) -> Vec<String> {
494        tasks
495            .iter()
496            .find(|t| t.id == end_id)
497            .map_or_else(Vec::new, |end| Self::trace_back(tasks, end))
498    }
499
500    /// Walk backwards from `sink` along tight edges (EF + lag == ES),
501    /// smallest id on ties. Lengths are scheduled lengths, not effort.
502    fn trace_back(tasks: &[Task], sink: &Task) -> Vec<String> {
503        let by_id: HashMap<&str, &Task> = tasks.iter().map(|t| (t.id.as_str(), t)).collect();
504        let mut path = vec![sink.id.clone()];
505        let mut current = sink;
506        let mut seen: HashSet<&str> = HashSet::from([sink.id.as_str()]);
507        while path.len() <= tasks.len() {
508            let next = current
509                .dependencies
510                .iter()
511                .filter_map(|d| by_id.get(d.as_str()).copied())
512                .filter(|p| {
513                    let lag = current.lag_by_dependency.get(&p.id).copied().unwrap_or(0.0);
514                    (p.earliest_finish + lag - current.earliest_start).abs() < TIGHT_EPS
515                })
516                .filter(|p| !seen.contains(p.id.as_str()))
517                .min_by(|a, b| a.id.cmp(&b.id));
518            let Some(pred) = next else { break };
519            seen.insert(pred.id.as_str());
520            path.push(pred.id.clone());
521            current = pred;
522        }
523        path.reverse();
524        path
525    }
526
527    /// Build the final result
528    fn build_result(
529        tasks: &[Task],
530        batches: Vec<TaskBatch>,
531        bottlenecks: Vec<Bottleneck>,
532        unscheduled: Vec<String>,
533    ) -> CriticalPathResult {
534        let critical_path = Self::trace_critical_path(tasks);
535        let project_length = tasks
536            .iter()
537            .map(|t| t.earliest_finish)
538            .fold(0.0_f32, f32::max);
539
540        let mut critical_tasks: Vec<&Task> = tasks.iter().filter(|t| t.is_critical).collect();
541        critical_tasks.sort_by(|a, b| {
542            a.earliest_start
543                .partial_cmp(&b.earliest_start)
544                .unwrap_or(std::cmp::Ordering::Equal)
545                .then_with(|| a.id.cmp(&b.id))
546        });
547        let critical_ids: Vec<String> = critical_tasks.iter().map(|t| t.id.clone()).collect();
548
549        let total_duration_sequential: f32 = tasks.iter().map(|t| t.effort_hours).sum();
550        let optimal_duration_parallel = project_length;
551
552        let speedup_factor = if optimal_duration_parallel > 0.0 {
553            total_duration_sequential / optimal_duration_parallel
554        } else {
555            1.0
556        };
557
558        CriticalPathResult {
559            total_tasks: tasks.len(),
560            critical_path,
561            critical_ids,
562            critical_path_duration: project_length,
563            total_duration_sequential,
564            optimal_duration_parallel,
565            speedup_factor,
566            parallelizable_batches: batches,
567            bottlenecks,
568            tasks: tasks.to_vec(),
569            unscheduled,
570        }
571    }
572}
573
574#[cfg(test)]
575#[allow(clippy::float_cmp)]
576mod tests {
577    use super::*;
578
579    fn make_task(id: &str, effort: f32, deps: Vec<&str>) -> Task {
580        Task {
581            id: id.to_string(),
582            name: format!("Task {id}"),
583            effort_hours: effort,
584            dependencies: deps.into_iter().map(String::from).collect(),
585            ..Default::default()
586        }
587    }
588
589    #[test]
590    fn calculate_terminates_on_duplicate_ids() {
591        // The second "a" closes a loop through "b" while the first is a root.
592        let mut tasks = vec![
593            make_task("a", 1.0, vec![]),
594            make_task("a", 1.0, vec!["b"]),
595            make_task("b", 1.0, vec!["a"]),
596        ];
597        let result = CpmAlgorithm::calculate(&mut tasks);
598        assert_eq!(result.unscheduled, vec!["a".to_string(), "b".to_string()]);
599    }
600
601    #[test]
602    fn test_simple_chain() {
603        // A -> B -> C (linear dependency)
604        let mut tasks = vec![
605            make_task("A", 2.0, vec![]),
606            make_task("B", 3.0, vec!["A"]),
607            make_task("C", 1.0, vec!["B"]),
608        ];
609
610        let result = CpmAlgorithm::calculate(&mut tasks);
611
612        // All tasks should be on critical path
613        assert_eq!(result.critical_path.len(), 3);
614        assert_eq!(result.critical_path_duration, 6.0);
615        // No parallelization possible
616        assert!((result.speedup_factor - 1.0).abs() < 0.01);
617    }
618
619    #[test]
620    fn test_parallel_tasks() {
621        // A -> B
622        // A -> C (B and C can run in parallel)
623        let mut tasks = vec![
624            make_task("A", 1.0, vec![]),
625            make_task("B", 2.0, vec!["A"]),
626            make_task("C", 3.0, vec!["A"]),
627        ];
628
629        let result = CpmAlgorithm::calculate(&mut tasks);
630
631        // Critical path: A -> C (1 + 3 = 4h)
632        assert_eq!(result.critical_path_duration, 4.0);
633        // Total sequential: 1 + 2 + 3 = 6h
634        assert_eq!(result.total_duration_sequential, 6.0);
635        // Speedup should be > 1
636        assert!(result.speedup_factor > 1.0);
637    }
638
639    #[test]
640    fn test_diamond_dependency() {
641        //     B
642        //   /   \
643        // A       D
644        //   \   /
645        //     C
646        let mut tasks = vec![
647            make_task("A", 1.0, vec![]),
648            make_task("B", 2.0, vec!["A"]),
649            make_task("C", 4.0, vec!["A"]),
650            make_task("D", 1.0, vec!["B", "C"]),
651        ];
652
653        let result = CpmAlgorithm::calculate(&mut tasks);
654
655        // Critical path: A -> C -> D (1 + 4 + 1 = 6h)
656        assert_eq!(result.critical_path_duration, 6.0);
657        // B should not be on critical path (has float)
658        assert!(!result.critical_path.contains(&"B".to_string()));
659    }
660
661    #[test]
662    fn test_bottleneck_identification() {
663        // A blocks B, C, D (high ROI)
664        let mut tasks = vec![
665            make_task("A", 1.0, vec![]),
666            make_task("B", 5.0, vec!["A"]),
667            make_task("C", 5.0, vec!["A"]),
668            make_task("D", 5.0, vec!["A"]),
669        ];
670
671        let result = CpmAlgorithm::calculate(&mut tasks);
672
673        // A should be identified as top bottleneck
674        assert!(!result.bottlenecks.is_empty());
675        assert_eq!(result.bottlenecks[0].task_id, "A");
676        // ROI = 15h blocked / 1h effort = 15
677        assert!(result.bottlenecks[0].roi >= 14.0);
678    }
679
680    #[test]
681    fn test_empty_tasks() {
682        let mut tasks: Vec<Task> = vec![];
683        let result = CpmAlgorithm::calculate(&mut tasks);
684        assert_eq!(result.total_tasks, 0);
685        assert!(result.critical_path.is_empty());
686    }
687
688    #[test]
689    fn test_single_task() {
690        let mut tasks = vec![make_task("A", 5.0, vec![])];
691        let result = CpmAlgorithm::calculate(&mut tasks);
692
693        assert_eq!(result.total_tasks, 1);
694        assert_eq!(result.critical_path_duration, 5.0);
695        assert_eq!(result.critical_path, vec!["A".to_string()]);
696    }
697
698    #[test]
699    fn test_cyclic_graph_is_reported_unscheduled() {
700        // A -> B -> C -> A forms a cycle: none of these can be scheduled.
701        let mut tasks = vec![
702            make_task("A", 1.0, vec!["C"]),
703            make_task("B", 1.0, vec!["A"]),
704            make_task("C", 1.0, vec!["B"]),
705        ];
706
707        let result = CpmAlgorithm::calculate(&mut tasks);
708
709        // The forward pass cannot drain the topo queue, so every member of
710        // the cycle must surface in `unscheduled` (sorted) rather than the
711        // result silently looking valid.
712        assert_eq!(
713            result.unscheduled,
714            vec!["A".to_string(), "B".to_string(), "C".to_string()]
715        );
716    }
717
718    #[test]
719    fn test_partial_cycle_reports_only_cycle_members() {
720        // ROOT is schedulable; X<->Y form a 2-cycle and are not.
721        let mut tasks = vec![
722            make_task("ROOT", 1.0, vec![]),
723            make_task("X", 1.0, vec!["ROOT", "Y"]),
724            make_task("Y", 1.0, vec!["X"]),
725        ];
726
727        let result = CpmAlgorithm::calculate(&mut tasks);
728
729        assert_eq!(result.unscheduled, vec!["X".to_string(), "Y".to_string()]);
730    }
731
732    #[test]
733    fn test_acyclic_graph_has_empty_unscheduled() {
734        let mut tasks = vec![make_task("A", 2.0, vec![]), make_task("B", 3.0, vec!["A"])];
735        let result = CpmAlgorithm::calculate(&mut tasks);
736        assert!(result.unscheduled.is_empty());
737    }
738
739    #[test]
740    fn test_batch_grouping() {
741        // Two independent tasks starting at same time
742        let mut tasks = vec![make_task("A", 2.0, vec![]), make_task("B", 3.0, vec![])];
743
744        let result = CpmAlgorithm::calculate(&mut tasks);
745
746        // Should be in same batch (start at time 0)
747        assert_eq!(result.parallelizable_batches.len(), 1);
748        assert_eq!(result.parallelizable_batches[0].tasks.len(), 2);
749        // Duration is max (3h), not sum (5h)
750        assert_eq!(result.parallelizable_batches[0].duration_hours, 3.0);
751        assert_eq!(result.parallelizable_batches[0].total_effort_hours, 5.0);
752    }
753}