utf8proj_solver/
bdd.rs

1//! BDD-based resource conflict detection and resolution
2//!
3//! Uses Binary Decision Diagrams to:
4//! - Detect resource overallocation conflicts
5//! - Find valid resource assignments
6//! - Suggest conflict resolutions
7//!
8//! This is a lightweight alternative to full SAT/SMT solving,
9//! using the biodivine-lib-bdd library.
10
11use biodivine_lib_bdd::BddVariableSetBuilder;
12use chrono::NaiveDate;
13use std::collections::{HashMap, HashSet};
14use utf8proj_core::{Project, Schedule};
15
16/// Result of BDD-based conflict analysis
17#[derive(Debug, Clone)]
18pub struct ConflictAnalysis {
19    /// Whether the schedule is conflict-free
20    pub is_valid: bool,
21    /// Detected conflicts
22    pub conflicts: Vec<ResourceConflict>,
23    /// Suggested resolutions (task shifts)
24    pub suggestions: Vec<ConflictResolution>,
25    /// BDD statistics
26    pub stats: BddStats,
27}
28
29/// A resource conflict at a specific time
30#[derive(Debug, Clone)]
31pub struct ResourceConflict {
32    /// Resource ID
33    pub resource_id: String,
34    /// Date of conflict
35    pub date: NaiveDate,
36    /// Tasks competing for the resource
37    pub competing_tasks: Vec<String>,
38    /// Required capacity
39    pub required: f64,
40    /// Available capacity
41    pub available: f64,
42}
43
44/// Suggested resolution for a conflict
45#[derive(Debug, Clone)]
46pub struct ConflictResolution {
47    /// Task to shift
48    pub task_id: String,
49    /// Shift direction
50    pub direction: ShiftDirection,
51    /// Number of days to shift
52    pub days: i64,
53    /// Conflicts resolved by this shift
54    pub resolves: usize,
55}
56
57/// Direction to shift a task
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ShiftDirection {
60    Earlier,
61    Later,
62}
63
64/// BDD operation statistics
65#[derive(Debug, Clone, Default)]
66pub struct BddStats {
67    /// Number of BDD variables created
68    pub variables: usize,
69    /// Number of BDD nodes in final result
70    pub nodes: usize,
71    /// Time spent in BDD operations (microseconds)
72    pub time_us: u64,
73}
74
75/// BDD-based resource conflict analyzer
76pub struct BddConflictAnalyzer {
77    /// Maximum time horizon in days (reserved for future use)
78    #[allow(dead_code)]
79    max_days: i64,
80}
81
82impl BddConflictAnalyzer {
83    /// Create a new analyzer with default settings
84    pub fn new() -> Self {
85        Self { max_days: 365 }
86    }
87
88    /// Create an analyzer with custom time horizon
89    pub fn with_max_days(max_days: i64) -> Self {
90        Self { max_days }
91    }
92
93    /// Analyze a schedule for resource conflicts using BDD
94    pub fn analyze(&self, project: &Project, schedule: &Schedule) -> ConflictAnalysis {
95        let start_time = std::time::Instant::now();
96
97        // Step 1: Collect resource allocations per day
98        let allocations = self.collect_allocations(project, schedule);
99
100        // Step 2: Build BDD for resource constraints
101        let (is_valid, conflicts, var_count, node_count) =
102            self.build_constraint_bdd(project, &allocations);
103
104        // Step 3: Generate resolution suggestions for conflicts
105        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    /// Collect resource allocations: (resource_id, date) -> [(task_id, units)]
126    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                // For each day the task runs
136                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    /// Build BDD constraint and detect conflicts
153    fn build_constraint_bdd(
154        &self,
155        project: &Project,
156        allocations: &HashMap<(String, NaiveDate), Vec<(String, f64)>>,
157    ) -> (bool, Vec<ResourceConflict>, usize, usize) {
158        // Build resource capacity map
159        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        // Check each (resource, date) for overallocation
168        // We use BDD to represent the constraint: sum(allocations) <= capacity
169        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        // Build a BDD representing valid states
185        // For this prototype, we create a simple satisfiability check
186        let (var_count, node_count) = self.build_validity_bdd(&conflicts, allocations);
187
188        (conflicts.is_empty(), conflicts, var_count, node_count)
189    }
190
191    /// Build BDD for validity checking
192    /// Returns (variable count, node count)
193    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); // Trivially true BDD
200        }
201
202        // Create BDD variables for each task involved in conflicts
203        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        // Build variable set
213        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        // Build constraint: for each conflict, at least one task must be shifted
223        // This is represented as: NOT(all conflicting tasks scheduled together)
224        let mut constraint = vars.mk_true();
225
226        for conflict in conflicts {
227            // At least one task in this conflict must not run at this time
228            // ~(t1 & t2 & ... & tn) = ~t1 | ~t2 | ... | ~tn
229            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                    // Add "not this task" to the clause
233                    let not_task = vars.mk_not_var(var);
234                    conflict_clause = conflict_clause.or(&not_task);
235                }
236            }
237            constraint = constraint.and(&conflict_clause);
238        }
239
240        let node_count = constraint.size();
241
242        // Check if there's a valid assignment
243        let _is_satisfiable = !constraint.is_false();
244
245        // For debugging: find satisfying assignments
246        if let Some(_witness) = constraint.sat_witness() {
247            // A valid assignment exists - some tasks can be shifted to resolve conflicts
248        }
249
250        (var_count, node_count)
251    }
252
253    /// Suggest resolutions for conflicts
254    fn suggest_resolutions(
255        &self,
256        conflicts: &[ResourceConflict],
257        schedule: &Schedule,
258    ) -> Vec<ConflictResolution> {
259        let mut suggestions = Vec::new();
260
261        // Group conflicts by task to find which tasks are most problematic
262        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        // Suggest shifting tasks that appear in most conflicts
270        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                // Suggest shifting based on slack
276                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                    // Non-critical task might be shiftable earlier
287                    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    /// Find optimal task shifts to resolve all conflicts using BDD
301    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()); // No shifts needed
310        }
311
312        // Use BDD to find minimal set of task shifts
313        self.solve_with_bdd(project, schedule, &analysis.conflicts)
314    }
315
316    /// Use BDD to solve for minimal task shifts
317    fn solve_with_bdd(
318        &self,
319        _project: &Project,
320        schedule: &Schedule,
321        conflicts: &[ResourceConflict],
322    ) -> Option<Vec<(String, i64)>> {
323        // Collect all tasks involved in conflicts
324        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        // Create BDD variables: shift_task_i means "shift task i by 1+ days"
336        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        // Build constraint: for each conflict, at least one task must be shifted
345        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        // Find a satisfying assignment with minimal shifts
358        // For now, just find any satisfying assignment
359        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                        // Get task's slack to determine shift amount
366                        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 // No valid resolution exists
381        }
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        // One resource with capacity 1
401        project.resources = vec![Resource::new("dev").name("Developer").capacity(1.0)];
402
403        // Two tasks that need the same resource at the same time
404        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        // Create a schedule where both tasks overlap (conflict!)
416        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        // Both tasks scheduled at the same time - conflict!
434        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        // Sequential schedule - no conflict
533        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        // Should suggest shifting task2 (it has slack)
674        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        // Project with no tasks and no resource assignments
710        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        // No conflicts for empty project
732        assert!(analysis.is_valid);
733        assert!(analysis.conflicts.is_empty());
734    }
735
736    #[test]
737    fn analyzer_default() {
738        // Tests lines 386-387: Default impl
739        let analyzer = BddConflictAnalyzer::default();
740        assert_eq!(analyzer.max_days, 365);
741    }
742
743    #[test]
744    fn find_optimal_resolution_no_conflict() {
745        // Tests line 309: when is_valid is true, return empty vec
746        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}