Skip to main content

u_schedule/
validation.rs

1//! Input validation for scheduling problems.
2//!
3//! Checks structural integrity of tasks, activities, and resources
4//! before scheduling. Detects:
5//! - Duplicate IDs
6//! - Missing resource references
7//! - Circular precedence dependencies (DAG validation)
8//! - Empty tasks
9//!
10//! # Reference
11//! Cormen et al. (2009), "Introduction to Algorithms", Ch. 22.4 (Topological Sort)
12
13use crate::models::{Resource, Task};
14use std::collections::{HashMap, HashSet};
15
16/// Validation result.
17pub type ValidationResult = Result<(), Vec<ValidationError>>;
18
19/// A validation error.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ValidationError {
22    /// Error category.
23    pub kind: ValidationErrorKind,
24    /// Human-readable description.
25    pub message: String,
26}
27
28/// Categories of validation errors.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ValidationErrorKind {
31    /// Two entities share the same ID.
32    DuplicateId,
33    /// An activity references a resource that doesn't exist.
34    InvalidResourceReference,
35    /// Precedence graph contains a cycle.
36    CyclicDependency,
37    /// A task has no activities.
38    EmptyTask,
39    /// An activity references a predecessor that doesn't exist.
40    InvalidPredecessor,
41}
42
43impl ValidationError {
44    fn new(kind: ValidationErrorKind, message: impl Into<String>) -> Self {
45        Self {
46            kind,
47            message: message.into(),
48        }
49    }
50}
51
52/// Validates the input data for a scheduling problem.
53///
54/// Checks:
55/// 1. No duplicate task IDs
56/// 2. No duplicate activity IDs (across all tasks)
57/// 3. No duplicate resource IDs
58/// 4. All tasks have at least one activity
59/// 5. All resource references in activities point to existing resources
60/// 6. All predecessor references point to existing activities
61/// 7. No circular precedence dependencies
62///
63/// # Returns
64/// `Ok(())` if all checks pass, `Err(errors)` with all detected issues.
65pub fn validate_input(tasks: &[Task], resources: &[Resource]) -> ValidationResult {
66    let mut errors = Vec::new();
67
68    // Collect resource IDs
69    let mut resource_ids = HashSet::new();
70    for r in resources {
71        if !resource_ids.insert(r.id.as_str()) {
72            errors.push(ValidationError::new(
73                ValidationErrorKind::DuplicateId,
74                format!("Duplicate resource ID: {}", r.id),
75            ));
76        }
77    }
78
79    // Collect task and activity IDs
80    let mut task_ids = HashSet::new();
81    let mut activity_ids = HashSet::new();
82
83    for task in tasks {
84        if !task_ids.insert(task.id.as_str()) {
85            errors.push(ValidationError::new(
86                ValidationErrorKind::DuplicateId,
87                format!("Duplicate task ID: {}", task.id),
88            ));
89        }
90
91        if task.activities.is_empty() {
92            errors.push(ValidationError::new(
93                ValidationErrorKind::EmptyTask,
94                format!("Task '{}' has no activities", task.id),
95            ));
96        }
97
98        for act in &task.activities {
99            if !activity_ids.insert(act.id.as_str()) {
100                errors.push(ValidationError::new(
101                    ValidationErrorKind::DuplicateId,
102                    format!("Duplicate activity ID: {}", act.id),
103                ));
104            }
105        }
106    }
107
108    // Check resource references
109    for task in tasks {
110        for act in &task.activities {
111            for req in &act.resource_requirements {
112                for cand in &req.candidates {
113                    if !resource_ids.contains(cand.as_str()) {
114                        errors.push(ValidationError::new(
115                            ValidationErrorKind::InvalidResourceReference,
116                            format!(
117                                "Activity '{}' references unknown resource '{}'",
118                                act.id, cand
119                            ),
120                        ));
121                    }
122                }
123            }
124        }
125    }
126
127    // Check predecessor references
128    for task in tasks {
129        for act in &task.activities {
130            for pred in &act.predecessors {
131                if !activity_ids.contains(pred.as_str()) {
132                    errors.push(ValidationError::new(
133                        ValidationErrorKind::InvalidPredecessor,
134                        format!(
135                            "Activity '{}' references unknown predecessor '{}'",
136                            act.id, pred
137                        ),
138                    ));
139                }
140            }
141        }
142    }
143
144    // Check for cycles in precedence graph (DFS-based)
145    if let Some(cycle_err) = detect_cycles(tasks) {
146        errors.push(cycle_err);
147    }
148
149    if errors.is_empty() {
150        Ok(())
151    } else {
152        Err(errors)
153    }
154}
155
156/// Detects cycles in the precedence graph using DFS.
157///
158/// # Algorithm
159/// Topological sort via DFS. If a back-edge is found (visiting a node
160/// currently in the recursion stack), a cycle exists.
161///
162/// # Reference
163/// Cormen et al. (2009), "Introduction to Algorithms", Ch. 22.4
164fn detect_cycles(tasks: &[Task]) -> Option<ValidationError> {
165    // Build adjacency list: activity_id → successors
166    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
167    let mut all_ids: HashSet<&str> = HashSet::new();
168
169    for task in tasks {
170        for act in &task.activities {
171            all_ids.insert(&act.id);
172            for pred in &act.predecessors {
173                adj.entry(pred.as_str()).or_default().push(act.id.as_str());
174            }
175        }
176    }
177
178    // DFS cycle detection
179    let mut visited = HashSet::new();
180    let mut in_stack = HashSet::new();
181
182    for &node in &all_ids {
183        if !visited.contains(node) && has_cycle_dfs(node, &adj, &mut visited, &mut in_stack) {
184            return Some(ValidationError::new(
185                ValidationErrorKind::CyclicDependency,
186                format!("Circular dependency detected involving activity '{node}'"),
187            ));
188        }
189    }
190
191    None
192}
193
194fn has_cycle_dfs<'a>(
195    node: &'a str,
196    adj: &HashMap<&'a str, Vec<&'a str>>,
197    visited: &mut HashSet<&'a str>,
198    in_stack: &mut HashSet<&'a str>,
199) -> bool {
200    visited.insert(node);
201    in_stack.insert(node);
202
203    if let Some(neighbors) = adj.get(node) {
204        for &next in neighbors {
205            if in_stack.contains(next) {
206                return true; // Back edge → cycle
207            }
208            if !visited.contains(next) && has_cycle_dfs(next, adj, visited, in_stack) {
209                return true;
210            }
211        }
212    }
213
214    in_stack.remove(node);
215    false
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::models::{Activity, ActivityDuration, Resource, ResourceRequirement, Task};
222
223    fn sample_resources() -> Vec<Resource> {
224        vec![
225            Resource::primary("M1").with_name("Machine 1"),
226            Resource::primary("M2").with_name("Machine 2"),
227            Resource::human("W1").with_name("Worker 1"),
228        ]
229    }
230
231    fn sample_tasks() -> Vec<Task> {
232        vec![
233            Task::new("J1")
234                .with_activity(
235                    Activity::new("O1", "J1", 0)
236                        .with_duration(ActivityDuration::fixed(1000))
237                        .with_requirement(
238                            ResourceRequirement::new("Machine").with_candidates(vec!["M1".into()]),
239                        ),
240                )
241                .with_activity(
242                    Activity::new("O2", "J1", 1)
243                        .with_duration(ActivityDuration::fixed(2000))
244                        .with_predecessor("O1")
245                        .with_requirement(
246                            ResourceRequirement::new("Machine").with_candidates(vec!["M2".into()]),
247                        ),
248                ),
249            Task::new("J2").with_activity(
250                Activity::new("O3", "J2", 0)
251                    .with_duration(ActivityDuration::fixed(1500))
252                    .with_requirement(
253                        ResourceRequirement::new("Machine").with_candidates(vec!["M1".into()]),
254                    ),
255            ),
256        ]
257    }
258
259    #[test]
260    fn test_valid_input() {
261        let tasks = sample_tasks();
262        let resources = sample_resources();
263        assert!(validate_input(&tasks, &resources).is_ok());
264    }
265
266    #[test]
267    fn test_duplicate_task_id() {
268        let tasks = vec![
269            Task::new("J1").with_activity(Activity::new("O1", "J1", 0).with_process_time(100)),
270            Task::new("J1").with_activity(Activity::new("O2", "J1", 0).with_process_time(100)),
271        ];
272        let resources = sample_resources();
273
274        let errors = validate_input(&tasks, &resources).unwrap_err();
275        assert!(errors
276            .iter()
277            .any(|e| e.kind == ValidationErrorKind::DuplicateId));
278    }
279
280    #[test]
281    fn test_duplicate_resource_id() {
282        let tasks = sample_tasks();
283        let resources = vec![Resource::primary("M1"), Resource::primary("M1")];
284
285        let errors = validate_input(&tasks, &resources).unwrap_err();
286        assert!(errors
287            .iter()
288            .any(|e| e.kind == ValidationErrorKind::DuplicateId && e.message.contains("resource")));
289    }
290
291    #[test]
292    fn test_empty_task() {
293        let tasks = vec![Task::new("empty")]; // No activities
294        let resources = sample_resources();
295
296        let errors = validate_input(&tasks, &resources).unwrap_err();
297        assert!(errors
298            .iter()
299            .any(|e| e.kind == ValidationErrorKind::EmptyTask));
300    }
301
302    #[test]
303    fn test_invalid_resource_reference() {
304        let tasks = vec![Task::new("J1").with_activity(
305            Activity::new("O1", "J1", 0)
306                .with_process_time(100)
307                .with_requirement(
308                    ResourceRequirement::new("Machine").with_candidates(vec!["NONEXISTENT".into()]),
309                ),
310        )];
311        let resources = sample_resources();
312
313        let errors = validate_input(&tasks, &resources).unwrap_err();
314        assert!(errors
315            .iter()
316            .any(|e| e.kind == ValidationErrorKind::InvalidResourceReference));
317    }
318
319    #[test]
320    fn test_invalid_predecessor() {
321        let tasks = vec![Task::new("J1").with_activity(
322            Activity::new("O1", "J1", 0)
323                .with_process_time(100)
324                .with_predecessor("NONEXISTENT"),
325        )];
326        let resources = sample_resources();
327
328        let errors = validate_input(&tasks, &resources).unwrap_err();
329        assert!(errors
330            .iter()
331            .any(|e| e.kind == ValidationErrorKind::InvalidPredecessor));
332    }
333
334    #[test]
335    fn test_cyclic_dependency() {
336        // O1 → O2 → O3 → O1 (cycle)
337        let tasks = vec![Task::new("J1")
338            .with_activity(
339                Activity::new("O1", "J1", 0)
340                    .with_process_time(100)
341                    .with_predecessor("O3"),
342            )
343            .with_activity(
344                Activity::new("O2", "J1", 1)
345                    .with_process_time(100)
346                    .with_predecessor("O1"),
347            )
348            .with_activity(
349                Activity::new("O3", "J1", 2)
350                    .with_process_time(100)
351                    .with_predecessor("O2"),
352            )];
353        let resources = sample_resources();
354
355        let errors = validate_input(&tasks, &resources).unwrap_err();
356        assert!(errors
357            .iter()
358            .any(|e| e.kind == ValidationErrorKind::CyclicDependency));
359    }
360
361    #[test]
362    fn test_no_cycle_in_chain() {
363        // O1 → O2 → O3 (linear chain, no cycle)
364        let tasks = vec![Task::new("J1")
365            .with_activity(Activity::new("O1", "J1", 0).with_process_time(100))
366            .with_activity(
367                Activity::new("O2", "J1", 1)
368                    .with_process_time(100)
369                    .with_predecessor("O1"),
370            )
371            .with_activity(
372                Activity::new("O3", "J1", 2)
373                    .with_process_time(100)
374                    .with_predecessor("O2"),
375            )];
376        let resources = sample_resources();
377
378        assert!(validate_input(&tasks, &resources).is_ok());
379    }
380
381    #[test]
382    fn test_multiple_errors() {
383        // Empty task + invalid resource reference
384        let tasks = vec![
385            Task::new("empty"), // Empty task
386            Task::new("J1").with_activity(
387                Activity::new("O1", "J1", 0)
388                    .with_process_time(100)
389                    .with_requirement(
390                        ResourceRequirement::new("M").with_candidates(vec!["UNKNOWN".into()]),
391                    ),
392            ),
393        ];
394        let resources = vec![];
395
396        let errors = validate_input(&tasks, &resources).unwrap_err();
397        assert!(errors.len() >= 2);
398    }
399}