1use biodivine_lib_bdd::BddVariableSetBuilder;
12use chrono::NaiveDate;
13use std::collections::{HashMap, HashSet};
14use utf8proj_core::{Project, Schedule};
15
16#[derive(Debug, Clone)]
18pub struct ConflictAnalysis {
19 pub is_valid: bool,
21 pub conflicts: Vec<ResourceConflict>,
23 pub suggestions: Vec<ConflictResolution>,
25 pub stats: BddStats,
27}
28
29#[derive(Debug, Clone)]
31pub struct ResourceConflict {
32 pub resource_id: String,
34 pub date: NaiveDate,
36 pub competing_tasks: Vec<String>,
38 pub required: f64,
40 pub available: f64,
42}
43
44#[derive(Debug, Clone)]
46pub struct ConflictResolution {
47 pub task_id: String,
49 pub direction: ShiftDirection,
51 pub days: i64,
53 pub resolves: usize,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ShiftDirection {
60 Earlier,
61 Later,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct BddStats {
67 pub variables: usize,
69 pub nodes: usize,
71 pub time_us: u64,
73}
74
75pub struct BddConflictAnalyzer {
77 #[allow(dead_code)]
79 max_days: i64,
80}
81
82impl BddConflictAnalyzer {
83 pub fn new() -> Self {
85 Self { max_days: 365 }
86 }
87
88 pub fn with_max_days(max_days: i64) -> Self {
90 Self { max_days }
91 }
92
93 pub fn analyze(&self, project: &Project, schedule: &Schedule) -> ConflictAnalysis {
95 let start_time = std::time::Instant::now();
96
97 let allocations = self.collect_allocations(project, schedule);
99
100 let (is_valid, conflicts, var_count, node_count) =
102 self.build_constraint_bdd(project, &allocations);
103
104 let suggestions = if !is_valid {
106 self.suggest_resolutions(&conflicts, schedule)
107 } else {
108 Vec::new()
109 };
110
111 let elapsed = start_time.elapsed();
112
113 ConflictAnalysis {
114 is_valid,
115 conflicts,
116 suggestions,
117 stats: BddStats {
118 variables: var_count,
119 nodes: node_count,
120 time_us: elapsed.as_micros() as u64,
121 },
122 }
123 }
124
125 fn collect_allocations(
127 &self,
128 _project: &Project,
129 schedule: &Schedule,
130 ) -> HashMap<(String, NaiveDate), Vec<(String, f64)>> {
131 let mut allocations: HashMap<(String, NaiveDate), Vec<(String, f64)>> = HashMap::new();
132
133 for (task_id, scheduled_task) in &schedule.tasks {
134 for assignment in &scheduled_task.assignments {
135 let mut current = scheduled_task.start;
137 while current <= scheduled_task.finish {
138 let key = (assignment.resource_id.clone(), current);
139 allocations
140 .entry(key)
141 .or_default()
142 .push((task_id.clone(), f64::from(assignment.units)));
143
144 current = current.succ_opt().unwrap_or(current);
145 }
146 }
147 }
148
149 allocations
150 }
151
152 fn build_constraint_bdd(
154 &self,
155 project: &Project,
156 allocations: &HashMap<(String, NaiveDate), Vec<(String, f64)>>,
157 ) -> (bool, Vec<ResourceConflict>, usize, usize) {
158 let capacities: HashMap<&str, f64> = project
160 .resources
161 .iter()
162 .map(|r| (r.id.as_str(), f64::from(r.capacity)))
163 .collect();
164
165 let mut conflicts = Vec::new();
166
167 for ((resource_id, date), tasks) in allocations {
170 let capacity = capacities.get(resource_id.as_str()).copied().unwrap_or(1.0);
171 let total_required: f64 = tasks.iter().map(|(_, units)| units).sum();
172
173 if total_required > capacity {
174 conflicts.push(ResourceConflict {
175 resource_id: resource_id.clone(),
176 date: *date,
177 competing_tasks: tasks.iter().map(|(id, _)| id.clone()).collect(),
178 required: total_required,
179 available: capacity,
180 });
181 }
182 }
183
184 let (var_count, node_count) = self.build_validity_bdd(&conflicts, allocations);
187
188 (conflicts.is_empty(), conflicts, var_count, node_count)
189 }
190
191 fn build_validity_bdd(
194 &self,
195 conflicts: &[ResourceConflict],
196 _allocations: &HashMap<(String, NaiveDate), Vec<(String, f64)>>,
197 ) -> (usize, usize) {
198 if conflicts.is_empty() {
199 return (0, 1); }
201
202 let conflict_tasks: HashSet<&str> = conflicts
204 .iter()
205 .flat_map(|c| c.competing_tasks.iter().map(|s| s.as_str()))
206 .collect();
207
208 if conflict_tasks.is_empty() {
209 return (0, 1);
210 }
211
212 let mut builder = BddVariableSetBuilder::new();
214 let task_vars: HashMap<&str, _> = conflict_tasks
215 .iter()
216 .map(|&task| (task, builder.make_variable(task)))
217 .collect();
218
219 let vars = builder.build();
220 let var_count = task_vars.len();
221
222 let mut constraint = vars.mk_true();
225
226 for conflict in conflicts {
227 let mut conflict_clause = vars.mk_false();
230 for task_id in &conflict.competing_tasks {
231 if let Some(&var) = task_vars.get(task_id.as_str()) {
232 let not_task = vars.mk_not_var(var);
234 conflict_clause = conflict_clause.or(¬_task);
235 }
236 }
237 constraint = constraint.and(&conflict_clause);
238 }
239
240 let node_count = constraint.size();
241
242 let _is_satisfiable = !constraint.is_false();
244
245 if let Some(_witness) = constraint.sat_witness() {
247 }
249
250 (var_count, node_count)
251 }
252
253 fn suggest_resolutions(
255 &self,
256 conflicts: &[ResourceConflict],
257 schedule: &Schedule,
258 ) -> Vec<ConflictResolution> {
259 let mut suggestions = Vec::new();
260
261 let mut task_conflict_count: HashMap<&str, usize> = HashMap::new();
263 for conflict in conflicts {
264 for task_id in &conflict.competing_tasks {
265 *task_conflict_count.entry(task_id.as_str()).or_default() += 1;
266 }
267 }
268
269 let mut task_counts: Vec<_> = task_conflict_count.into_iter().collect();
271 task_counts.sort_by(|a, b| b.1.cmp(&a.1));
272
273 for (task_id, conflict_count) in task_counts.into_iter().take(5) {
274 if let Some(scheduled_task) = schedule.tasks.get(task_id) {
275 let slack_days = scheduled_task.slack.as_days() as i64;
277
278 if slack_days > 0 {
279 suggestions.push(ConflictResolution {
280 task_id: task_id.to_string(),
281 direction: ShiftDirection::Later,
282 days: slack_days.min(5),
283 resolves: conflict_count,
284 });
285 } else {
286 suggestions.push(ConflictResolution {
288 task_id: task_id.to_string(),
289 direction: ShiftDirection::Later,
290 days: 1,
291 resolves: conflict_count,
292 });
293 }
294 }
295 }
296
297 suggestions
298 }
299
300 pub fn find_optimal_resolution(
302 &self,
303 project: &Project,
304 schedule: &Schedule,
305 ) -> Option<Vec<(String, i64)>> {
306 let analysis = self.analyze(project, schedule);
307
308 if analysis.is_valid {
309 return Some(Vec::new()); }
311
312 self.solve_with_bdd(project, schedule, &analysis.conflicts)
314 }
315
316 fn solve_with_bdd(
318 &self,
319 _project: &Project,
320 schedule: &Schedule,
321 conflicts: &[ResourceConflict],
322 ) -> Option<Vec<(String, i64)>> {
323 let conflict_tasks: Vec<&str> = conflicts
325 .iter()
326 .flat_map(|c| c.competing_tasks.iter().map(|s| s.as_str()))
327 .collect::<HashSet<_>>()
328 .into_iter()
329 .collect();
330
331 if conflict_tasks.is_empty() {
332 return Some(Vec::new());
333 }
334
335 let mut builder = BddVariableSetBuilder::new();
337 let shift_vars: Vec<_> = conflict_tasks
338 .iter()
339 .map(|&task| (task, builder.make_variable(task)))
340 .collect();
341
342 let vars = builder.build();
343
344 let mut constraint = vars.mk_true();
346
347 for conflict in conflicts {
348 let mut at_least_one_shifted = vars.mk_false();
349 for task_id in &conflict.competing_tasks {
350 if let Some((_, var)) = shift_vars.iter().find(|(t, _)| *t == task_id.as_str()) {
351 at_least_one_shifted = at_least_one_shifted.or(&vars.mk_var(*var));
352 }
353 }
354 constraint = constraint.and(&at_least_one_shifted);
355 }
356
357 if let Some(witness) = constraint.sat_witness() {
360 let shifts: Vec<(String, i64)> = shift_vars
361 .iter()
362 .enumerate()
363 .filter_map(|(idx, (task, _))| {
364 if witness.value(biodivine_lib_bdd::BddVariable::from_index(idx)) {
365 let shift_days = schedule
367 .tasks
368 .get(*task)
369 .map(|t| (t.slack.as_days() as i64).max(1))
370 .unwrap_or(1);
371 Some((task.to_string(), shift_days))
372 } else {
373 None
374 }
375 })
376 .collect();
377
378 Some(shifts)
379 } else {
380 None }
382 }
383}
384
385impl Default for BddConflictAnalyzer {
386 fn default() -> Self {
387 Self::new()
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use utf8proj_core::{Duration, Resource, ScheduledTask, Task, TaskStatus};
395
396 fn make_project_with_resource_conflict() -> (Project, Schedule) {
397 let mut project = Project::new("Conflict Test");
398 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
399
400 project.resources = vec![Resource::new("dev").name("Developer").capacity(1.0)];
402
403 project.tasks = vec![
405 Task::new("task1")
406 .name("Task 1")
407 .effort(Duration::days(5))
408 .assign("dev"),
409 Task::new("task2")
410 .name("Task 2")
411 .effort(Duration::days(5))
412 .assign("dev"),
413 ];
414
415 let project_end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
417 let mut schedule = Schedule {
418 tasks: HashMap::new(),
419 critical_path: vec!["task1".to_string()],
420 project_duration: Duration::days(5),
421 project_end,
422 total_cost: None,
423 total_cost_range: None,
424 project_progress: 0,
425 project_baseline_finish: project_end,
426 project_forecast_finish: project_end,
427 project_variance_days: 0,
428 planned_value: 0,
429 earned_value: 0,
430 spi: 1.0,
431 };
432
433 let start = project.start;
435 let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
436
437 schedule.tasks.insert(
438 "task1".to_string(),
439 ScheduledTask {
440 task_id: "task1".to_string(),
441 start,
442 finish,
443 duration: Duration::days(5),
444 assignments: vec![utf8proj_core::Assignment {
445 resource_id: "dev".to_string(),
446 start,
447 finish,
448 units: 1.0,
449 cost: None,
450 cost_range: None,
451 is_abstract: false,
452 effort_days: None,
453 }],
454 slack: Duration::zero(),
455 is_critical: true,
456 early_start: start,
457 early_finish: finish,
458 late_start: start,
459 late_finish: finish,
460 forecast_start: start,
461 forecast_finish: finish,
462 remaining_duration: Duration::days(5),
463 percent_complete: 0,
464 status: TaskStatus::NotStarted,
465 baseline_start: start,
466 baseline_finish: finish,
467 start_variance_days: 0,
468 finish_variance_days: 0,
469 cost_range: None,
470 has_abstract_assignments: false,
471 },
472 );
473
474 schedule.tasks.insert(
475 "task2".to_string(),
476 ScheduledTask {
477 task_id: "task2".to_string(),
478 start,
479 finish,
480 duration: Duration::days(5),
481 assignments: vec![utf8proj_core::Assignment {
482 resource_id: "dev".to_string(),
483 start,
484 finish,
485 units: 1.0,
486 cost: None,
487 cost_range: None,
488 is_abstract: false,
489 effort_days: None,
490 }],
491 slack: Duration::days(5),
492 is_critical: false,
493 early_start: start,
494 early_finish: finish,
495 late_start: NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(),
496 late_finish: NaiveDate::from_ymd_opt(2025, 1, 17).unwrap(),
497 forecast_start: start,
498 forecast_finish: finish,
499 remaining_duration: Duration::days(5),
500 percent_complete: 0,
501 status: TaskStatus::NotStarted,
502 baseline_start: start,
503 baseline_finish: finish,
504 start_variance_days: 0,
505 finish_variance_days: 0,
506 cost_range: None,
507 has_abstract_assignments: false,
508 },
509 );
510
511 (project, schedule)
512 }
513
514 fn make_project_no_conflict() -> (Project, Schedule) {
515 let mut project = Project::new("No Conflict");
516 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
517
518 project.resources = vec![Resource::new("dev").name("Developer").capacity(1.0)];
519
520 project.tasks = vec![
521 Task::new("task1")
522 .name("Task 1")
523 .effort(Duration::days(5))
524 .assign("dev"),
525 Task::new("task2")
526 .name("Task 2")
527 .effort(Duration::days(5))
528 .assign("dev")
529 .depends_on("task1"),
530 ];
531
532 let project_end = NaiveDate::from_ymd_opt(2025, 1, 17).unwrap();
534 let mut schedule = Schedule {
535 tasks: HashMap::new(),
536 critical_path: vec!["task1".to_string(), "task2".to_string()],
537 project_duration: Duration::days(10),
538 project_end,
539 total_cost: None,
540 total_cost_range: None,
541 project_progress: 0,
542 project_baseline_finish: project_end,
543 project_forecast_finish: project_end,
544 project_variance_days: 0,
545 planned_value: 0,
546 earned_value: 0,
547 spi: 1.0,
548 };
549
550 let start1 = project.start;
551 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
552 let start2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
553 let finish2 = NaiveDate::from_ymd_opt(2025, 1, 17).unwrap();
554
555 schedule.tasks.insert(
556 "task1".to_string(),
557 ScheduledTask {
558 task_id: "task1".to_string(),
559 start: start1,
560 finish: finish1,
561 duration: Duration::days(5),
562 assignments: vec![utf8proj_core::Assignment {
563 resource_id: "dev".to_string(),
564 start: start1,
565 finish: finish1,
566 units: 1.0,
567 cost: None,
568 cost_range: None,
569 is_abstract: false,
570 effort_days: None,
571 }],
572 slack: Duration::zero(),
573 is_critical: true,
574 early_start: start1,
575 early_finish: finish1,
576 late_start: start1,
577 late_finish: finish1,
578 forecast_start: start1,
579 forecast_finish: finish1,
580 remaining_duration: Duration::days(5),
581 percent_complete: 0,
582 status: TaskStatus::NotStarted,
583 baseline_start: start1,
584 baseline_finish: finish1,
585 start_variance_days: 0,
586 finish_variance_days: 0,
587 cost_range: None,
588 has_abstract_assignments: false,
589 },
590 );
591
592 schedule.tasks.insert(
593 "task2".to_string(),
594 ScheduledTask {
595 task_id: "task2".to_string(),
596 start: start2,
597 finish: finish2,
598 duration: Duration::days(5),
599 assignments: vec![utf8proj_core::Assignment {
600 resource_id: "dev".to_string(),
601 start: start2,
602 finish: finish2,
603 units: 1.0,
604 cost: None,
605 cost_range: None,
606 is_abstract: false,
607 effort_days: None,
608 }],
609 slack: Duration::zero(),
610 is_critical: true,
611 early_start: start2,
612 early_finish: finish2,
613 late_start: start2,
614 late_finish: finish2,
615 forecast_start: start2,
616 forecast_finish: finish2,
617 remaining_duration: Duration::days(5),
618 percent_complete: 0,
619 status: TaskStatus::NotStarted,
620 cost_range: None,
621 has_abstract_assignments: false,
622 baseline_start: start2,
623 baseline_finish: finish2,
624 start_variance_days: 0,
625 finish_variance_days: 0,
626 },
627 );
628
629 (project, schedule)
630 }
631
632 #[test]
633 fn detect_resource_conflict() {
634 let (project, schedule) = make_project_with_resource_conflict();
635 let analyzer = BddConflictAnalyzer::new();
636
637 let analysis = analyzer.analyze(&project, &schedule);
638
639 assert!(!analysis.is_valid, "Should detect conflict");
640 assert!(!analysis.conflicts.is_empty(), "Should have conflicts");
641
642 let conflict = &analysis.conflicts[0];
643 assert_eq!(conflict.resource_id, "dev");
644 assert!(conflict.competing_tasks.contains(&"task1".to_string()));
645 assert!(conflict.competing_tasks.contains(&"task2".to_string()));
646 assert_eq!(conflict.required, 2.0);
647 assert_eq!(conflict.available, 1.0);
648 }
649
650 #[test]
651 fn no_conflict_when_sequential() {
652 let (project, schedule) = make_project_no_conflict();
653 let analyzer = BddConflictAnalyzer::new();
654
655 let analysis = analyzer.analyze(&project, &schedule);
656
657 assert!(analysis.is_valid, "Should be conflict-free");
658 assert!(analysis.conflicts.is_empty(), "Should have no conflicts");
659 }
660
661 #[test]
662 fn suggest_resolution() {
663 let (project, schedule) = make_project_with_resource_conflict();
664 let analyzer = BddConflictAnalyzer::new();
665
666 let analysis = analyzer.analyze(&project, &schedule);
667
668 assert!(
669 !analysis.suggestions.is_empty(),
670 "Should suggest resolution"
671 );
672
673 let suggestion = &analysis.suggestions[0];
675 assert!(suggestion.resolves > 0);
676 }
677
678 #[test]
679 fn find_optimal_resolution() {
680 let (project, schedule) = make_project_with_resource_conflict();
681 let analyzer = BddConflictAnalyzer::new();
682
683 let resolution = analyzer.find_optimal_resolution(&project, &schedule);
684
685 assert!(resolution.is_some(), "Should find resolution");
686 let shifts = resolution.unwrap();
687 assert!(!shifts.is_empty(), "Should have task shifts");
688 }
689
690 #[test]
691 fn bdd_stats_recorded() {
692 let (project, schedule) = make_project_with_resource_conflict();
693 let analyzer = BddConflictAnalyzer::new();
694
695 let analysis = analyzer.analyze(&project, &schedule);
696
697 assert!(analysis.stats.variables > 0, "Should have variables");
698 assert!(analysis.stats.nodes > 0, "Should have nodes");
699 }
700
701 #[test]
702 fn analyzer_with_custom_max_days() {
703 let analyzer = BddConflictAnalyzer::with_max_days(730);
704 assert_eq!(analyzer.max_days, 730);
705 }
706
707 #[test]
708 fn empty_project_no_conflicts() {
709 let project = Project::new("Empty");
711 let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
712 let schedule = Schedule {
713 tasks: HashMap::new(),
714 critical_path: vec![],
715 project_duration: Duration::zero(),
716 project_end,
717 total_cost: None,
718 total_cost_range: None,
719 project_progress: 0,
720 project_baseline_finish: project_end,
721 project_forecast_finish: project_end,
722 project_variance_days: 0,
723 planned_value: 0,
724 earned_value: 0,
725 spi: 1.0,
726 };
727
728 let analyzer = BddConflictAnalyzer::new();
729 let analysis = analyzer.analyze(&project, &schedule);
730
731 assert!(analysis.is_valid);
733 assert!(analysis.conflicts.is_empty());
734 }
735
736 #[test]
737 fn analyzer_default() {
738 let analyzer = BddConflictAnalyzer::default();
740 assert_eq!(analyzer.max_days, 365);
741 }
742
743 #[test]
744 fn find_optimal_resolution_no_conflict() {
745 let (project, schedule) = make_project_no_conflict();
747 let analyzer = BddConflictAnalyzer::new();
748
749 let resolution = analyzer.find_optimal_resolution(&project, &schedule);
750
751 assert!(resolution.is_some());
752 assert!(
753 resolution.unwrap().is_empty(),
754 "No shifts needed for valid schedule"
755 );
756 }
757}