1use crate::task::{Bottleneck, CriticalPathResult, Task, TaskBatch};
12use std::collections::{HashMap, HashSet, VecDeque};
13
14pub struct CpmAlgorithm;
16
17impl CpmAlgorithm {
18 pub fn calculate(tasks: &mut [Task]) -> CriticalPathResult {
26 if tasks.is_empty() {
27 return CriticalPathResult::default();
28 }
29
30 let (successors, predecessors) = Self::build_dependency_graphs(tasks);
32
33 let mut unscheduled = Self::forward_pass(tasks, &predecessors);
37
38 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 Self::calculate_float(tasks);
59
60 let batches = Self::identify_parallel_batches(tasks);
62
63 let bottlenecks = Self::identify_bottlenecks(tasks, &successors);
65
66 Self::build_result(tasks, batches, bottlenecks, unscheduled)
68 }
69
70 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 for task in tasks {
79 successors.entry(task.id.clone()).or_default();
80 predecessors.entry(task.id.clone()).or_default();
81 }
82
83 for task in tasks {
85 for dep in &task.dependencies {
86 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 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 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 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 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 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 let current_ef = match task_map.get(¤t_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 for task in tasks.iter_mut() {
167 if task.dependencies.contains(¤t_id) {
168 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 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 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 fn backward_pass(tasks: &mut [Task], successors: &HashMap<String, Vec<String>>) -> Vec<String> {
216 let project_duration = tasks
218 .iter()
219 .map(|t| t.earliest_finish)
220 .fold(0.0_f32, f32::max);
221
222 let task_map: HashMap<String, usize> = tasks
224 .iter()
225 .enumerate()
226 .map(|(i, t)| (t.id.clone(), i))
227 .collect();
228
229 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 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 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 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 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 fn calculate_float(tasks: &mut [Task]) {
296 for task in tasks.iter_mut() {
297 task.float = task.latest_start - task.earliest_start;
298 task.is_critical = task.float.abs() < 0.001;
300 }
301 }
302
303 fn identify_parallel_batches(tasks: &[Task]) -> Vec<TaskBatch> {
305 let mut batches_map: HashMap<i32, Vec<&Task>> = HashMap::new();
307
308 for task in tasks {
309 let es_key = (task.earliest_start * 10.0).round() as i32;
311 batches_map.entry(es_key).or_default().push(task);
312 }
313
314 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 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 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 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 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 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 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(¤t) {
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 fn build_result(
419 tasks: &[Task],
420 batches: Vec<TaskBatch>,
421 bottlenecks: Vec<Bottleneck>,
422 unscheduled: Vec<String>,
423 ) -> CriticalPathResult {
424 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 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 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 assert_eq!(result.critical_path.len(), 3);
490 assert_eq!(result.critical_path_duration, 6.0);
491 assert!((result.speedup_factor - 1.0).abs() < 0.01);
493 }
494
495 #[test]
496 fn test_parallel_tasks() {
497 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 assert_eq!(result.critical_path_duration, 4.0);
509 assert_eq!(result.total_duration_sequential, 6.0);
511 assert!(result.speedup_factor > 1.0);
513 }
514
515 #[test]
516 fn test_diamond_dependency() {
517 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 assert_eq!(result.critical_path_duration, 6.0);
533 assert!(!result.critical_path.contains(&"B".to_string()));
535 }
536
537 #[test]
538 fn test_bottleneck_identification() {
539 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 assert!(!result.bottlenecks.is_empty());
551 assert_eq!(result.bottlenecks[0].task_id, "A");
552 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 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 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 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 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 assert_eq!(result.parallelizable_batches.len(), 1);
624 assert_eq!(result.parallelizable_batches[0].tasks.len(), 2);
625 assert_eq!(result.parallelizable_batches[0].duration_hours, 3.0);
627 assert_eq!(result.parallelizable_batches[0].total_effort_hours, 5.0);
628 }
629}