utf8proj_solver/
cpm.rs

1//! Critical Path Method Implementation
2//!
3//! Textbook CPM algorithm operating on a SchedulingGraph.
4//!
5//! References:
6//!   - Kelley & Walker (1959) "Critical-Path Planning and Scheduling"
7//!   - PMI PMBOK Guide, Chapter 6
8//!
9//! # Algorithm
10//!
11//! 1. Topological sort (done in dag.rs)
12//! 2. Forward pass: Compute ES (Early Start) and EF (Early Finish)
13//! 3. Backward pass: Compute LS (Late Start) and LF (Late Finish)
14//! 4. Slack calculation: Slack = LS - ES (must be >= 0)
15//! 5. Critical path: Tasks where Slack == 0
16
17use crate::dag::{DependencyEdge, SchedulingGraph};
18use std::collections::HashMap;
19use utf8proj_core::{DependencyType, TaskId};
20
21/// Errors during CPM scheduling
22#[derive(Debug, Clone, PartialEq)]
23pub enum CpmError {
24    /// CPM invariant violated - slack should never be negative
25    NegativeSlack { task: TaskId, slack: i64 },
26    /// Empty graph
27    EmptyGraph,
28}
29
30impl std::fmt::Display for CpmError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            CpmError::NegativeSlack { task, slack } => {
34                write!(
35                    f,
36                    "CPM invariant violated: task '{}' has negative slack ({})",
37                    task, slack
38                )
39            }
40            CpmError::EmptyGraph => write!(f, "Cannot schedule empty graph"),
41        }
42    }
43}
44
45impl std::error::Error for CpmError {}
46
47/// Result of CPM scheduling for a single task
48#[derive(Debug, Clone)]
49pub struct CpmResult {
50    pub task_id: TaskId,
51    /// Early Start (days from project start)
52    pub es: i64,
53    /// Early Finish (days from project start)
54    pub ef: i64,
55    /// Late Start (days from project start)
56    pub ls: i64,
57    /// Late Finish (days from project start)
58    pub lf: i64,
59    /// Total Slack (days) - ALWAYS >= 0 for valid CPM
60    pub total_slack: i64,
61    /// Free Slack (days)
62    pub free_slack: i64,
63    /// On critical path (total_slack == 0)
64    pub is_critical: bool,
65    /// Duration in days
66    pub duration: i64,
67}
68
69/// Complete CPM schedule
70#[derive(Debug)]
71pub struct CpmSchedule {
72    /// Results for each task
73    pub results: HashMap<TaskId, CpmResult>,
74    /// Critical path (tasks with zero slack, in order)
75    pub critical_path: Vec<TaskId>,
76    /// Project start (day 0)
77    pub project_start: i64,
78    /// Project end (max EF)
79    pub project_end: i64,
80}
81
82/// CPM scheduler operating on flattened graph
83pub struct CpmScheduler;
84
85impl CpmScheduler {
86    pub fn new() -> Self {
87        Self
88    }
89
90    /// Schedule using Critical Path Method
91    ///
92    /// The graph must already be topologically sorted.
93    pub fn schedule(&self, graph: &SchedulingGraph) -> Result<CpmSchedule, CpmError> {
94        if graph.tasks.is_empty() {
95            return Err(CpmError::EmptyGraph);
96        }
97
98        let mut es: HashMap<TaskId, i64> = HashMap::new();
99        let mut ef: HashMap<TaskId, i64> = HashMap::new();
100        let mut ls: HashMap<TaskId, i64> = HashMap::new();
101        let mut lf: HashMap<TaskId, i64> = HashMap::new();
102
103        // Get durations
104        let duration: HashMap<TaskId, i64> = graph
105            .tasks
106            .iter()
107            .map(|t| (t.id.clone(), t.duration_days))
108            .collect();
109
110        // ════════════════════════════════════════════════════════════════════
111        // FORWARD PASS: Compute Early Start (ES) and Early Finish (EF)
112        // ════════════════════════════════════════════════════════════════════
113        //
114        // For each task in topological order:
115        //   ES = max(constraint from each predecessor), or 0 if no predecessors
116        //   EF = ES + duration
117
118        for task_id in &graph.topo_order {
119            let task_duration = duration[task_id];
120
121            let early_start = if let Some(edges) = graph.predecessors.get(task_id) {
122                if edges.is_empty() {
123                    0 // Project start
124                } else {
125                    edges
126                        .iter()
127                        .map(|edge| {
128                            compute_successor_es(
129                                edge,
130                                ef[&edge.from],
131                                es[&edge.from],
132                                task_duration,
133                            )
134                        })
135                        .max()
136                        .unwrap_or(0)
137                }
138            } else {
139                0
140            };
141
142            let early_finish = early_start + task_duration;
143
144            es.insert(task_id.clone(), early_start);
145            ef.insert(task_id.clone(), early_finish);
146        }
147
148        // Project end is the maximum EF
149        let project_end = ef.values().cloned().max().unwrap_or(0);
150
151        // ════════════════════════════════════════════════════════════════════
152        // BACKWARD PASS: Compute Late Start (LS) and Late Finish (LF)
153        // ════════════════════════════════════════════════════════════════════
154        //
155        // For each task in REVERSE topological order:
156        //   LF = min(constraint from each successor), or project_end if no successors
157        //   LS = LF - duration
158
159        for task_id in graph.topo_order.iter().rev() {
160            let task_duration = duration[task_id];
161
162            let late_finish = if let Some(edges) = graph.successors.get(task_id) {
163                if edges.is_empty() {
164                    project_end
165                } else {
166                    edges
167                        .iter()
168                        .map(|edge| {
169                            compute_predecessor_lf(edge, ls[&edge.to], lf[&edge.to], task_duration)
170                        })
171                        .min()
172                        .unwrap_or(project_end)
173                }
174            } else {
175                project_end
176            };
177
178            let late_start = late_finish - task_duration;
179
180            lf.insert(task_id.clone(), late_finish);
181            ls.insert(task_id.clone(), late_start);
182        }
183
184        // ════════════════════════════════════════════════════════════════════
185        // SLACK CALCULATION
186        // ════════════════════════════════════════════════════════════════════
187        //
188        // Total Slack = LS - ES = LF - EF (must be >= 0)
189        // Free Slack  = min(ES of successors) - EF
190        // Critical    = Total Slack == 0
191
192        let mut results: HashMap<TaskId, CpmResult> = HashMap::new();
193        let mut critical_path: Vec<TaskId> = Vec::new();
194
195        for task_id in &graph.topo_order {
196            let task_duration = duration[task_id];
197            let task_es = es[task_id];
198            let task_ef = ef[task_id];
199            let task_ls = ls[task_id];
200            let task_lf = lf[task_id];
201
202            let total_slack = task_ls - task_es;
203
204            // INVARIANT: Slack must be non-negative
205            if total_slack < 0 {
206                return Err(CpmError::NegativeSlack {
207                    task: task_id.clone(),
208                    slack: total_slack,
209                });
210            }
211
212            // Free slack: how much can this task slip without affecting successors
213            let free_slack = if let Some(edges) = graph.successors.get(task_id) {
214                if edges.is_empty() {
215                    total_slack
216                } else {
217                    edges
218                        .iter()
219                        .map(|edge| es[&edge.to])
220                        .min()
221                        .map(|min_succ_es| min_succ_es - task_ef)
222                        .unwrap_or(total_slack)
223                        .max(0)
224                }
225            } else {
226                total_slack
227            };
228
229            let is_critical = total_slack == 0;
230            if is_critical && task_duration > 0 {
231                critical_path.push(task_id.clone());
232            }
233
234            results.insert(
235                task_id.clone(),
236                CpmResult {
237                    task_id: task_id.clone(),
238                    es: task_es,
239                    ef: task_ef,
240                    ls: task_ls,
241                    lf: task_lf,
242                    total_slack,
243                    free_slack,
244                    is_critical,
245                    duration: task_duration,
246                },
247            );
248        }
249
250        Ok(CpmSchedule {
251            results,
252            critical_path,
253            project_start: 0,
254            project_end,
255        })
256    }
257}
258
259impl Default for CpmScheduler {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265/// Compute the ES constraint for a successor based on dependency type
266///
267/// For FS and SS, the constraint is directly on ES.
268/// For FF and SF, the constraint is on EF, so we convert to ES by subtracting duration.
269fn compute_successor_es(
270    edge: &DependencyEdge,
271    pred_ef: i64,
272    pred_es: i64,
273    succ_duration: i64,
274) -> i64 {
275    let lag = edge.lag_days;
276
277    match edge.dep_type {
278        DependencyType::FinishToStart => {
279            // Successor starts after predecessor finishes
280            // ES(succ) >= EF(pred) + lag
281            pred_ef + lag
282        }
283        DependencyType::StartToStart => {
284            // Successor starts after/with predecessor starts
285            // ES(succ) >= ES(pred) + lag
286            pred_es + lag
287        }
288        DependencyType::FinishToFinish => {
289            // Successor finishes after/with predecessor finishes
290            // EF(succ) >= EF(pred) + lag
291            // Since EF = ES + duration, we have:
292            // ES(succ) >= EF(pred) + lag - duration(succ)
293            pred_ef + lag - succ_duration
294        }
295        DependencyType::StartToFinish => {
296            // Successor finishes after/with predecessor starts
297            // EF(succ) >= ES(pred) + lag
298            // Since EF = ES + duration:
299            // ES(succ) >= ES(pred) + lag - duration(succ)
300            pred_es + lag - succ_duration
301        }
302    }
303}
304
305/// Compute the LF constraint for a predecessor based on dependency type
306///
307/// For FS and FF, the constraint is directly on LF.
308/// For SS and SF, the constraint is on LS, so we convert to LF by adding duration.
309fn compute_predecessor_lf(
310    edge: &DependencyEdge,
311    succ_ls: i64,
312    succ_lf: i64,
313    pred_duration: i64,
314) -> i64 {
315    let lag = edge.lag_days;
316
317    match edge.dep_type {
318        DependencyType::FinishToStart => {
319            // Predecessor must finish before successor starts (minus lag)
320            // LF(pred) <= LS(succ) - lag
321            succ_ls - lag
322        }
323        DependencyType::StartToStart => {
324            // Predecessor must start before successor starts
325            // LS(pred) <= LS(succ) - lag
326            // Since LS = LF - duration, we have:
327            // LF(pred) <= LS(succ) - lag + duration(pred)
328            succ_ls - lag + pred_duration
329        }
330        DependencyType::FinishToFinish => {
331            // Predecessor must finish before successor finishes
332            // LF(pred) <= LF(succ) - lag
333            succ_lf - lag
334        }
335        DependencyType::StartToFinish => {
336            // Predecessor must start before successor finishes
337            // LS(pred) <= LF(succ) - lag
338            // Since LS = LF - duration:
339            // LF(pred) <= LF(succ) - lag + duration(pred)
340            succ_lf - lag + pred_duration
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::dag::SchedulingGraph;
349    use utf8proj_core::{Duration, Task};
350
351    fn make_tasks_with_deps(tasks: &[(&str, i64, &[&str])]) -> Vec<Task> {
352        tasks
353            .iter()
354            .map(|(id, dur, deps)| {
355                let mut task = Task::new(*id).duration(Duration::days(*dur));
356                for dep in *deps {
357                    task = task.depends_on(*dep);
358                }
359                task
360            })
361            .collect()
362    }
363
364    #[test]
365    fn test_single_task() {
366        let tasks = vec![Task::new("a").duration(Duration::days(5))];
367        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
368        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
369
370        let a = &schedule.results["a"];
371        assert_eq!(a.es, 0);
372        assert_eq!(a.ef, 5);
373        assert_eq!(a.ls, 0);
374        assert_eq!(a.lf, 5);
375        assert_eq!(a.total_slack, 0);
376        assert!(a.is_critical);
377        assert_eq!(schedule.project_end, 5);
378    }
379
380    #[test]
381    fn test_sequential_chain() {
382        // A(5) -> B(3) -> C(2) = 10 days total
383        let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &["a"]), ("c", 2, &["b"])]);
384
385        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
386        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
387
388        assert_eq!(schedule.project_end, 10);
389
390        // All on critical path
391        assert!(schedule.results["a"].is_critical);
392        assert!(schedule.results["b"].is_critical);
393        assert!(schedule.results["c"].is_critical);
394
395        // Verify dates
396        assert_eq!(schedule.results["a"].es, 0);
397        assert_eq!(schedule.results["a"].ef, 5);
398        assert_eq!(schedule.results["b"].es, 5);
399        assert_eq!(schedule.results["b"].ef, 8);
400        assert_eq!(schedule.results["c"].es, 8);
401        assert_eq!(schedule.results["c"].ef, 10);
402    }
403
404    #[test]
405    fn test_slack_never_negative() {
406        // Complex network to verify invariant
407        let tasks = make_tasks_with_deps(&[
408            ("start", 0, &[]),
409            ("a", 5, &["start"]),
410            ("b", 8, &["start"]),
411            ("c", 3, &["a"]),
412            ("d", 4, &["b"]),
413            ("e", 6, &["c", "d"]),
414            ("f", 2, &["a"]),
415            ("end", 0, &["e", "f"]),
416        ]);
417
418        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
419        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
420
421        for (id, result) in &schedule.results {
422            assert!(
423                result.total_slack >= 0,
424                "Task {} has negative slack: {}",
425                id,
426                result.total_slack
427            );
428        }
429    }
430
431    #[test]
432    fn test_parallel_paths_with_slack() {
433        // A(5) ---> C(2)
434        //           |
435        // B(3) -----+
436        //
437        // Critical: A -> C (7 days)
438        // B has slack
439
440        let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
441
442        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
443        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
444
445        assert_eq!(schedule.project_end, 7);
446
447        // A is critical
448        assert!(schedule.results["a"].is_critical);
449        assert_eq!(schedule.results["a"].total_slack, 0);
450
451        // B has slack (can start as late as day 2)
452        assert!(!schedule.results["b"].is_critical);
453        assert_eq!(schedule.results["b"].total_slack, 2);
454        assert_eq!(schedule.results["b"].ls, 2);
455
456        // C is critical
457        assert!(schedule.results["c"].is_critical);
458    }
459
460    #[test]
461    fn test_critical_path_has_zero_slack() {
462        let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
463
464        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
465        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
466
467        for task_id in &schedule.critical_path {
468            let result = &schedule.results[task_id];
469            assert_eq!(
470                result.total_slack, 0,
471                "Critical task {} has non-zero slack: {}",
472                task_id, result.total_slack
473            );
474        }
475    }
476
477    #[test]
478    fn test_empty_graph_error() {
479        let tasks: Vec<Task> = vec![];
480        let graph = SchedulingGraph::from_wbs(&tasks);
481
482        // Empty WBS produces empty graph
483        assert!(graph.is_err() || graph.as_ref().unwrap().tasks.is_empty());
484
485        if let Ok(g) = graph {
486            let result = CpmScheduler::new().schedule(&g);
487            assert!(matches!(result, Err(CpmError::EmptyGraph)));
488        }
489    }
490
491    #[test]
492    fn test_cpm_error_display() {
493        let err = CpmError::NegativeSlack {
494            task: "test_task".to_string(),
495            slack: -5,
496        };
497        let msg = format!("{}", err);
498        assert!(msg.contains("test_task"));
499        assert!(msg.contains("-5"));
500
501        let err2 = CpmError::EmptyGraph;
502        let msg2 = format!("{}", err2);
503        assert!(msg2.contains("empty"));
504    }
505
506    #[test]
507    fn test_cpm_scheduler_default() {
508        let scheduler = CpmScheduler::default();
509        // Just verify default() works (covers line 239-240)
510        let tasks = vec![Task::new("a").duration(Duration::days(1))];
511        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
512        let schedule = scheduler.schedule(&graph);
513        assert!(schedule.is_ok());
514    }
515
516    #[test]
517    fn test_dependency_type_coverage() {
518        use utf8proj_core::Dependency;
519
520        // Test that SS, FF, SF dependency types are handled in forward pass
521        // These tests just exercise the code paths for coverage
522
523        // SS: Start-to-Start
524        let tasks_ss = vec![
525            Task::new("a").duration(Duration::days(5)),
526            Task::new("b").duration(Duration::days(3)),
527            Task::new("c")
528                .duration(Duration::days(2))
529                .depends_on("a") // FS to a
530                .with_dependency(Dependency {
531                    predecessor: "b".to_string(),
532                    dep_type: DependencyType::StartToStart,
533                    lag: None,
534                }),
535        ];
536        let graph = SchedulingGraph::from_wbs(&tasks_ss).unwrap();
537        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
538        assert!(schedule.results.contains_key("c"));
539
540        // FF: Finish-to-Finish
541        let tasks_ff = vec![
542            Task::new("a").duration(Duration::days(5)),
543            Task::new("b").duration(Duration::days(3)),
544            Task::new("c")
545                .duration(Duration::days(2))
546                .depends_on("a") // FS to a
547                .with_dependency(Dependency {
548                    predecessor: "b".to_string(),
549                    dep_type: DependencyType::FinishToFinish,
550                    lag: None,
551                }),
552        ];
553        let graph = SchedulingGraph::from_wbs(&tasks_ff).unwrap();
554        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
555        assert!(schedule.results.contains_key("c"));
556
557        // SF: Start-to-Finish
558        let tasks_sf = vec![
559            Task::new("a").duration(Duration::days(5)),
560            Task::new("b").duration(Duration::days(3)),
561            Task::new("c")
562                .duration(Duration::days(2))
563                .depends_on("a") // FS to a
564                .with_dependency(Dependency {
565                    predecessor: "b".to_string(),
566                    dep_type: DependencyType::StartToFinish,
567                    lag: None,
568                }),
569        ];
570        let graph = SchedulingGraph::from_wbs(&tasks_sf).unwrap();
571        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
572        assert!(schedule.results.contains_key("c"));
573    }
574
575    #[test]
576    fn test_dependency_with_lag() {
577        use utf8proj_core::Dependency;
578
579        // Test dependencies with lag values
580        let tasks = vec![
581            Task::new("a").duration(Duration::days(5)),
582            Task::new("b").duration(Duration::days(3)),
583            Task::new("c")
584                .duration(Duration::days(2))
585                .depends_on("a")
586                .with_dependency(Dependency {
587                    predecessor: "b".to_string(),
588                    dep_type: DependencyType::StartToStart,
589                    lag: Some(Duration::days(2)),
590                }),
591        ];
592
593        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
594        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
595
596        // Just verify it schedules without error
597        assert!(schedule.results.contains_key("c"));
598        assert!(schedule.project_end > 0);
599    }
600
601    #[test]
602    fn test_free_slack_calculation() {
603        // A(5) -> C(2)
604        // B(3) -> C(2)
605        // A is critical (longer), B has slack
606        let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
607
608        let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
609        let schedule = CpmScheduler::new().schedule(&graph).unwrap();
610
611        // B has free slack: min(ES of successors) - EF(B)
612        // ES(C) = 5 (from A), EF(B) = 3
613        // Free slack = 5 - 3 = 2
614        assert_eq!(schedule.results["b"].free_slack, 2);
615
616        // A has no free slack (critical)
617        assert_eq!(schedule.results["a"].free_slack, 0);
618    }
619}