From 6aeaa29dc1d25633f8b99044f98de51be0b068bf Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 17:04:56 +0800 Subject: [PATCH] ops: add schedule conflict detection --- Cargo.lock | 6 + crates/ops/apsis-conflict/Cargo.toml | 4 + crates/ops/apsis-conflict/src/conflict.rs | 252 +++++++++++++ crates/ops/apsis-conflict/src/detect.rs | 271 ++++++++++++++ crates/ops/apsis-conflict/src/lib.rs | 57 +++ crates/ops/apsis-conflict/tests/detect.rs | 410 ++++++++++++++++++++++ 6 files changed, 1000 insertions(+) create mode 100644 crates/ops/apsis-conflict/src/conflict.rs create mode 100644 crates/ops/apsis-conflict/src/detect.rs create mode 100644 crates/ops/apsis-conflict/tests/detect.rs diff --git a/Cargo.lock b/Cargo.lock index 97e1711..db3c5c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,12 @@ dependencies = [ [[package]] name = "apsis-conflict" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-resource", + "apsis-schedule", + "apsis-task", +] [[package]] name = "apsis-constellation" diff --git a/crates/ops/apsis-conflict/Cargo.toml b/crates/ops/apsis-conflict/Cargo.toml index 12e22dd..dd02e52 100644 --- a/crates/ops/apsis-conflict/Cargo.toml +++ b/crates/ops/apsis-conflict/Cargo.toml @@ -11,3 +11,7 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true +apsis-task.workspace = true +apsis-schedule.workspace = true diff --git a/crates/ops/apsis-conflict/src/conflict.rs b/crates/ops/apsis-conflict/src/conflict.rs new file mode 100644 index 0000000..afea035 --- /dev/null +++ b/crates/ops/apsis-conflict/src/conflict.rs @@ -0,0 +1,252 @@ +//! The conflict type and the report that collects them. + +use apsis_resource::ResourceId; +use apsis_task::TaskId; +use core::fmt; + +/// The kind of problem a conflict describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ConflictKind { + /// Two placements need more of a resource than it has. + Capacity, + /// A placement falls outside the time window of its task. + Window, + /// A placement does not agree with its task. + Requirement, + /// A placement falls outside the availability of a resource. + Availability, + /// A task that must follow another starts too early. + Precedence, +} + +impl fmt::Display for ConflictKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::Capacity => "capacity", + Self::Window => "window", + Self::Requirement => "requirement", + Self::Availability => "availability", + Self::Precedence => "precedence", + }; + f.write_str(text) + } +} + +/// One problem found in a schedule. +/// +/// The conflict names the tasks involved and, where it applies, the resource +/// and the instant at which the problem occurs. That is enough for an +/// operator to locate the placement without re-deriving the analysis. +#[derive(Debug, Clone, PartialEq)] +pub struct Conflict { + /// The kind of problem. + pub kind: ConflictKind, + /// The task the conflict is centred on. + pub task: TaskId, + /// The other task involved, for a conflict between two placements. + pub other: Option, + /// The resource involved, where the conflict is about one. + pub resource: Option, + /// The time at which the problem occurs, in seconds. + /// + /// For a capacity conflict this is the instant at which the demand + /// exceeds the capacity. For a window conflict it is the offending edge + /// of the placement. + pub at: f64, + /// A description of the problem. + pub detail: String, +} + +impl Conflict { + /// Creates a conflict. + pub fn new(kind: ConflictKind, task: TaskId, at: f64, detail: impl Into) -> Self { + Self { + kind, + task, + other: None, + resource: None, + at, + detail: detail.into(), + } + } + + /// Returns a copy of this conflict involving another task. + pub fn with_other(mut self, other: TaskId) -> Self { + self.other = Some(other); + self + } + + /// Returns a copy of this conflict involving a resource. + pub fn with_resource(mut self, resource: ResourceId) -> Self { + self.resource = Some(resource); + self + } + + /// Returns `true` when the conflict concerns a placement that should be + /// moved rather than one that should be dropped. + /// + /// A capacity or availability conflict can often be repaired by moving + /// the placement, while a requirement conflict means the placement does + /// not belong in the schedule at all. + pub const fn is_movable(&self) -> bool { + matches!( + self.kind, + ConflictKind::Capacity | ConflictKind::Availability + ) + } +} + +impl fmt::Display for Conflict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} conflict on {} at {}: {}", + self.kind, self.task, self.at, self.detail + )?; + if let Some(other) = self.other { + write!(f, " (with {other})")?; + } + if let Some(resource) = self.resource { + write!(f, " (on {resource})")?; + } + Ok(()) + } +} + +/// The conflicts found in a schedule. +/// +/// The report is ordered by time and then by kind, so that reading it +/// follows the plan. The counts by kind are provided because a planner +/// usually wants to know how many of each problem there are before deciding +/// what to do. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ConflictReport { + conflicts: Vec, + offered_tasks: usize, + placed_tasks: usize, +} + +impl ConflictReport { + /// Creates a report from a list of conflicts and the task counts. + /// + /// The conflicts are sorted by time, then by kind, then by task, so that + /// the order does not depend on the order they were found. + pub fn new(mut conflicts: Vec, offered_tasks: usize, placed_tasks: usize) -> Self { + conflicts.sort_by(|first, second| { + first + .at + .partial_cmp(&second.at) + .unwrap_or(core::cmp::Ordering::Equal) + .then_with(|| kind_order(first.kind).cmp(&kind_order(second.kind))) + .then_with(|| first.task.cmp(&second.task)) + .then_with(|| first.other.cmp(&second.other)) + }); + Self { + conflicts, + offered_tasks, + placed_tasks, + } + } + + /// Returns the conflicts, in time order. + pub fn conflicts(&self) -> &[Conflict] { + &self.conflicts + } + + /// Returns the number of conflicts. + pub fn len(&self) -> usize { + self.conflicts.len() + } + + /// Returns `true` when there are no conflicts. + /// + /// This is the conventional spelling of [`ConflictReport::is_clear`], and + /// the two always agree. + pub fn is_empty(&self) -> bool { + self.conflicts.is_empty() + } + + /// Returns `true` when there are no conflicts. + /// + /// A clear report means the schedule is feasible with respect to every + /// check this crate performs, not that it is optimal. + pub fn is_clear(&self) -> bool { + self.conflicts.is_empty() + } + + /// Returns `true` when there is at least one conflict. + pub fn has_conflicts(&self) -> bool { + !self.conflicts.is_empty() + } + + /// Returns the number of conflicts of one kind. + pub fn count_of(&self, kind: ConflictKind) -> usize { + self.conflicts + .iter() + .filter(|conflict| conflict.kind == kind) + .count() + } + + /// Returns the conflicts of one kind, in time order. + pub fn of_kind(&self, kind: ConflictKind) -> Vec<&Conflict> { + self.conflicts + .iter() + .filter(|conflict| conflict.kind == kind) + .collect() + } + + /// Returns the tasks involved in at least one conflict. + /// + /// A task appears once even when it has several conflicts, which is what + /// a repair pass wants: the set of tasks to reconsider. + pub fn conflicting_tasks(&self) -> Vec { + let mut tasks: Vec = Vec::new(); + for conflict in &self.conflicts { + if !tasks.contains(&conflict.task) { + tasks.push(conflict.task); + } + if let Some(other) = conflict.other { + if !tasks.contains(&other) { + tasks.push(other); + } + } + } + tasks + } + + /// Returns the number of tasks that were offered to the schedule. + pub fn offered_tasks(&self) -> usize { + self.offered_tasks + } + + /// Returns the number of tasks that were placed. + pub fn placed_tasks(&self) -> usize { + self.placed_tasks + } + + /// Returns a one line summary of the report. + pub fn summary(&self) -> String { + format!( + "{} conflict(s) among {} placed of {} offered task(s)", + self.conflicts.len(), + self.placed_tasks, + self.offered_tasks + ) + } +} + +/// Returns a sort key that groups the kinds in a readable order. +/// +/// The order runs from the problems that invalidate a plan to the ones that +/// merely make it expensive, so that reading the report top to bottom +/// addresses the most serious problems first. +const fn kind_order(kind: ConflictKind) -> u8 { + match kind { + ConflictKind::Requirement => 0, + ConflictKind::Window => 1, + ConflictKind::Capacity => 2, + ConflictKind::Availability => 3, + ConflictKind::Precedence => 4, + } +} diff --git a/crates/ops/apsis-conflict/src/detect.rs b/crates/ops/apsis-conflict/src/detect.rs new file mode 100644 index 0000000..4d4b567 --- /dev/null +++ b/crates/ops/apsis-conflict/src/detect.rs @@ -0,0 +1,271 @@ +//! The conflict detection itself. + +use crate::conflict::{Conflict, ConflictKind, ConflictReport}; +use apsis_resource::{ResourceId, ResourceSet}; +use apsis_schedule::Schedule; +use apsis_task::{Task, TaskId}; + +/// A precedence constraint: one task must finish before another starts. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Precedence { + /// The task that must finish first. + pub before: TaskId, + /// The task that must start after. + pub after: TaskId, +} + +impl Precedence { + /// Creates a precedence constraint. + pub const fn new(before: TaskId, after: TaskId) -> Self { + Self { before, after } + } +} + +/// Returns the placements of a task, which is at most one. +pub fn placements_of(schedule: &Schedule, task: TaskId) -> Option<&apsis_schedule::Placement> { + schedule.placement_of(task) +} + +/// Returns every conflict in a schedule. +/// +/// The checks are performed in full: the report contains every problem found, +/// not just the first. That is deliberate, because a planner that is handed a +/// plan usually wants to know how bad it is before deciding whether to repair +/// or rebuild. +/// +/// The checks are: +/// +/// - every placement's task exists in the list, and the durations agree; +/// - every placement falls inside its task's window; +/// - no resource exceeds its capacity at any instant; +/// - every placement falls inside the availability windows of each resource +/// it requires. +/// +/// The resource sweep examines the boundaries of the placements, because the +/// total demand on a resource is constant between them, so the check is exact +/// and takes time proportional to the number of placements rather than to the +/// length of the plan. +pub fn detect(schedule: &Schedule, tasks: &[Task], resources: &ResourceSet) -> ConflictReport { + let mut conflicts = Vec::new(); + + // The requirement and window checks need only the placement and its + // task. + for placement in schedule.placements() { + let task = tasks.iter().find(|task| task.id == placement.task); + let Some(task) = task else { + conflicts.push( + Conflict::new( + ConflictKind::Requirement, + placement.task, + placement.start, + "the task is not in the task list", + ) + .with_resource(ResourceId::new(0)), + ); + continue; + }; + + if (placement.duration - task.duration).abs() > 1e-12 { + conflicts.push(Conflict::new( + ConflictKind::Requirement, + placement.task, + placement.start, + format!( + "placed for {} s but the task lasts {} s", + placement.duration, task.duration + ), + )); + } + + if placement.start < task.earliest_start { + conflicts.push(Conflict::new( + ConflictKind::Window, + placement.task, + placement.start, + format!( + "starts at {}, before the window opens at {}", + placement.start, task.earliest_start + ), + )); + } + if placement.end() > task.latest_end { + conflicts.push(Conflict::new( + ConflictKind::Window, + placement.task, + placement.end(), + format!( + "ends at {}, after the window closes at {}", + placement.end(), + task.latest_end + ), + )); + } + } + + // The availability check: a placement must fall inside a window of every + // resource it requires. + for placement in schedule.placements() { + let Some(task) = tasks.iter().find(|task| task.id == placement.task) else { + continue; + }; + for requirement in &task.requirements { + let Some(resource) = resources.get(requirement.resource) else { + conflicts.push( + Conflict::new( + ConflictKind::Requirement, + placement.task, + placement.start, + format!("{} does not exist", requirement.resource), + ) + .with_resource(requirement.resource), + ); + continue; + }; + // A demand greater than the capacity can never be met, which is a + // requirement problem rather than a placement problem. + if requirement.demand > resource.capacity() + 1e-9 { + conflicts.push( + Conflict::new( + ConflictKind::Requirement, + placement.task, + placement.start, + format!( + "demand {} exceeds the capacity {} of {}", + requirement.demand, + resource.capacity(), + resource.id + ), + ) + .with_resource(requirement.resource), + ); + continue; + } + if !resource.is_available_over(placement.start, placement.duration) { + conflicts.push( + Conflict::new( + ConflictKind::Availability, + placement.task, + placement.start, + format!( + "{} is not available from {} for {} s", + resource.id, placement.start, placement.duration + ), + ) + .with_resource(requirement.resource), + ); + } + } + } + + // The capacity check, one resource at a time. + for resource in resources.resources() { + // Each event is a time, a change in demand, and the task responsible, + // so that a conflict can name the pair. + let mut events: Vec<(f64, f64, TaskId)> = Vec::new(); + for placement in schedule.placements() { + let Some(task) = tasks.iter().find(|task| task.id == placement.task) else { + continue; + }; + if let Some(demand) = task.demand_for(resource.id) { + events.push((placement.start, demand, placement.task)); + events.push((placement.end(), -demand, placement.task)); + } + } + if events.is_empty() { + continue; + } + // A release sorts before an acquisition at the same instant, so two + // placements that touch are not counted as overlapping. + events.sort_by(|first, second| { + first + .0 + .partial_cmp(&second.0) + .unwrap_or(core::cmp::Ordering::Equal) + .then_with(|| { + first + .1 + .partial_cmp(&second.1) + .unwrap_or(core::cmp::Ordering::Equal) + }) + .then_with(|| first.2.cmp(&second.2)) + }); + + let mut current = 0.0; + let mut holders: Vec = Vec::new(); + for (time, change, task) in events { + if change > 0.0 { + holders.push(task); + } else { + holders.retain(|holder| *holder != task); + } + current += change; + if current > resource.capacity() + 1e-9 { + // Report the pair that first pushes the resource over, so + // that the conflict names two tasks rather than only the one + // that arrived last. + let mut involved = holders.clone(); + involved.sort(); + let first = involved.first().copied(); + let second = involved.get(1).copied(); + let detail = format!( + "demand {} exceeds the capacity {} of {}", + current, + resource.capacity(), + resource.id + ); + let mut conflict = + Conflict::new(ConflictKind::Capacity, first.unwrap_or(task), time, detail) + .with_resource(resource.id); + if let Some(second) = second { + conflict = conflict.with_other(second); + } + conflicts.push(conflict); + // Report each resource once, at the first instant it is over + // committed. A second report at a later instant would add + // nothing a repair pass can use. + break; + } + } + } + + ConflictReport::new(conflicts, tasks.len(), schedule.len()) +} + +/// Returns the precedence conflicts among a set of constraints. +/// +/// A precedence conflict occurs when the task that must come second starts +/// before the one that must come first has finished. A constraint whose +/// tasks are not both placed is not a conflict, because nothing has been +/// promised about them. +/// +/// The constraints are checked as given, so they must be acyclic. A cycle is +/// a contradiction in the caller's own model and is not detected here. +pub fn detect_precedence(schedule: &Schedule, constraints: &[Precedence]) -> Vec { + let mut conflicts = Vec::new(); + for constraint in constraints { + let (Some(before), Some(after)) = ( + schedule.placement_of(constraint.before), + schedule.placement_of(constraint.after), + ) else { + continue; + }; + if after.start < before.end() { + conflicts.push( + Conflict::new( + ConflictKind::Precedence, + constraint.after, + after.start, + format!( + "{} must follow {} but starts at {} before it ends at {}", + constraint.after, + constraint.before, + after.start, + before.end() + ), + ) + .with_other(constraint.before), + ); + } + } + conflicts +} diff --git a/crates/ops/apsis-conflict/src/lib.rs b/crates/ops/apsis-conflict/src/lib.rs index 368f26f..2b88791 100644 --- a/crates/ops/apsis-conflict/src/lib.rs +++ b/crates/ops/apsis-conflict/src/lib.rs @@ -1 +1,58 @@ //! Resource and time conflict detection. +//! +//! A schedule can be wrong in several ways, and this crate finds them. It +//! reports conflicts rather than refusing them, so that a plan loaded from a +//! file can be checked in full and every problem listed at once instead of +//! one per attempt. +//! +//! Five kinds of conflict are detected: +//! +//! - **Capacity**: two placements need more of a resource at the same instant +//! than the resource has. +//! - **Window**: a placement starts before its task may, or ends after it +//! must. +//! - **Requirement**: a placement's task is not known, or its duration does +//! not match the schedule. +//! - **Availability**: a placement falls outside the availability windows of +//! a resource it needs, so the resource would not actually be there. +//! - **Precedence**: a task that must follow another starts before it ends. +//! +//! The distinction between a capacity conflict and an availability conflict +//! matters in practice: the first says the plan is over committed, the second +//! says the plan asks for a resource at a time when it is switched off or out +//! of view, which is a different repair. +//! +//! ``` +//! use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +//! use apsis_schedule::Schedule; +//! use apsis_task::{Task, TaskId}; +//! use apsis_conflict::{ConflictKind, detect}; +//! +//! let mut station = Resource::new(ResourceId::new(0), 1.0); +//! station.set_windows(&[Window::new(0.0, 1_000.0)]).expect("usable"); +//! let resources = ResourceSet::from_resources(vec![station]).expect("distinct"); +//! +//! let task = Task::new(TaskId::new(0), 60.0) +//! .try_require(ResourceId::new(0), 1.0) +//! .expect("the resource is new"); +//! let mut schedule = Schedule::new(); +//! schedule.place(&task, 100.0).expect("the task fits"); +//! +//! let report = detect(&schedule, &[task], &resources); +//! assert!(report.is_clear()); +//! assert_eq!(report.conflicts().len(), 0); +//! let _ = ConflictKind::Capacity; +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod conflict; +mod detect; + +pub use conflict::{Conflict, ConflictKind, ConflictReport}; +pub use detect::{Precedence, detect, detect_precedence, placements_of}; + +pub use apsis_resource::{ResourceId, ResourceSet}; +pub use apsis_schedule::Schedule; +pub use apsis_task::{Task, TaskId}; diff --git a/crates/ops/apsis-conflict/tests/detect.rs b/crates/ops/apsis-conflict/tests/detect.rs new file mode 100644 index 0000000..3033ce7 --- /dev/null +++ b/crates/ops/apsis-conflict/tests/detect.rs @@ -0,0 +1,410 @@ +//! Tests for conflict detection. + +use apsis_conflict::{Conflict, ConflictKind, Precedence, Schedule, detect, detect_precedence}; +use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +use apsis_task::{Task, TaskId}; + +/// A resource available from 0 to 1 000 seconds. +fn resource(id: usize, capacity: f64) -> Resource { + let mut resource = Resource::new(ResourceId::new(id), capacity); + resource + .set_windows(&[Window::new(0.0, 1_000.0)]) + .expect("the window is usable"); + resource +} + +/// A task requiring one unit of each named resource. +fn task(id: usize, duration: f64, resources: &[usize]) -> Task { + let mut task = Task::new(TaskId::new(id), duration); + for resource in resources { + task = task + .try_require(ResourceId::new(*resource), 1.0) + .expect("the resource is new"); + } + task +} + +/// The standard resource set used by the tests. +fn resources() -> ResourceSet { + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]) + .expect("the identifiers are distinct") +} + +#[test] +fn a_sound_schedule_has_no_conflicts() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0]), task(1, 30.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 100.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + assert!(report.is_clear()); + assert!(report.is_empty()); + assert!(!report.has_conflicts()); + assert_eq!(report.len(), 0); + assert_eq!(report.placed_tasks(), 2); + assert_eq!(report.offered_tasks(), 2); + assert!(report.conflicting_tasks().is_empty()); + assert!(report.summary().contains("0 conflict")); +} + +#[test] +fn a_capacity_conflict_is_found() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + assert!(report.has_conflicts()); + assert_eq!(report.count_of(ConflictKind::Capacity), 1); + let conflict = report.of_kind(ConflictKind::Capacity)[0]; + assert_eq!(conflict.resource, Some(ResourceId::new(0))); + // The conflict names both tasks, so a repair pass knows the pair. + assert!(conflict.other.is_some()); + // The instant reported is the start of the second placement. + assert_eq!(conflict.at, 30.0); + assert!(conflict.is_movable()); +} + +#[test] +fn back_to_back_placements_do_not_conflict() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 60.0).expect("fits"); + assert!(detect(&schedule, &tasks, &resources).is_clear()); +} + +#[test] +fn a_window_conflict_is_found_at_the_start() { + let resources = resources(); + let tasks = [task(0, 60.0, &[0]).with_window(100.0, 1_000.0)]; + let mut schedule = Schedule::new(); + // The schedule refuses a placement before the window, so the conflict is + // constructed directly to test the detector. + schedule.place(&tasks[0], 100.0).expect("fits"); + // A task list whose window has moved since the schedule was built. + let moved = vec![task(0, 60.0, &[0]).with_window(200.0, 1_000.0)]; + + let report = detect(&schedule, &moved, &resources); + assert_eq!(report.count_of(ConflictKind::Window), 1); + let conflict = report.of_kind(ConflictKind::Window)[0]; + assert_eq!(conflict.at, 100.0); + assert!(!conflict.is_movable()); +} + +#[test] +fn a_window_conflict_is_found_at_the_end() { + let resources = resources(); + let tasks = [task(0, 60.0, &[0]).with_window(0.0, 1_000.0)]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 100.0).expect("fits"); + // A task list whose window closes earlier than the placement ends. + let shortened = vec![task(0, 60.0, &[0]).with_window(0.0, 120.0)]; + + let report = detect(&schedule, &shortened, &resources); + assert_eq!(report.count_of(ConflictKind::Window), 1); + let conflict = report.of_kind(ConflictKind::Window)[0]; + // The instant reported is the end of the placement. + assert_eq!(conflict.at, 160.0); +} + +#[test] +fn a_requirement_conflict_is_found_for_a_missing_task() { + let resources = resources(); + let tasks = [task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + + // The task list is empty, so the placement has no task. + let report = detect(&schedule, &[], &resources); + assert_eq!(report.count_of(ConflictKind::Requirement), 1); + assert_eq!(report.placed_tasks(), 1); + assert_eq!(report.offered_tasks(), 0); +} + +#[test] +fn a_requirement_conflict_is_found_for_a_duration_mismatch() { + let resources = resources(); + let original = task(0, 60.0, &[0]); + let mut schedule = Schedule::new(); + schedule.place(&original, 0.0).expect("fits"); + + let changed = vec![task(0, 90.0, &[0])]; + let report = detect(&schedule, &changed, &resources); + assert_eq!(report.count_of(ConflictKind::Requirement), 1); +} + +#[test] +fn a_requirement_conflict_is_found_for_a_missing_resource() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[9])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + assert_eq!(report.count_of(ConflictKind::Requirement), 1); + let conflict = report.of_kind(ConflictKind::Requirement)[0]; + assert_eq!(conflict.resource, Some(ResourceId::new(9))); +} + +#[test] +fn a_requirement_conflict_is_found_for_an_impossible_demand() { + let resources = resources(); + // A demand of two on a resource of capacity one can never be met. + let tasks = vec![ + Task::new(TaskId::new(0), 60.0) + .try_require(ResourceId::new(0), 2.0) + .expect("the resource is new"), + ]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + assert_eq!(report.count_of(ConflictKind::Requirement), 1); + let conflict = report.of_kind(ConflictKind::Requirement)[0]; + assert!(conflict.detail.contains("exceeds the capacity")); +} + +#[test] +fn an_availability_conflict_is_found_outside_the_windows() { + // The resource is available only from 200 to 300 seconds. + let mut limited = Resource::new(ResourceId::new(0), 1.0); + limited + .set_windows(&[Window::new(200.0, 300.0)]) + .expect("the window is usable"); + let resources = ResourceSet::from_resources(vec![limited]).expect("distinct"); + + let tasks = vec![task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + assert_eq!(report.count_of(ConflictKind::Availability), 1); + let conflict = report.of_kind(ConflictKind::Availability)[0]; + assert_eq!(conflict.resource, Some(ResourceId::new(0))); + assert!(conflict.is_movable()); +} + +#[test] +fn an_availability_conflict_is_found_for_a_placement_that_overruns_a_window() { + // The resource is available only from 0 to 120 seconds, so a placement + // from 100 for 60 seconds overruns it. + let mut limited = Resource::new(ResourceId::new(0), 1.0); + limited + .set_windows(&[Window::new(0.0, 120.0)]) + .expect("the window is usable"); + let resources = ResourceSet::from_resources(vec![limited]).expect("distinct"); + + let tasks = vec![task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 100.0).expect("fits"); + assert_eq!( + detect(&schedule, &tasks, &resources).count_of(ConflictKind::Availability), + 1 + ); +} + +#[test] +fn a_placement_inside_the_availability_has_no_conflict() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 200.0).expect("fits"); + assert!(detect(&schedule, &tasks, &resources).is_clear()); +} + +#[test] +fn several_conflicts_are_reported_together() { + // The detector reports every problem, not just the first. + let resources = resources(); + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + + // A task list that also moves the window of the first task. + let changed = vec![ + task(0, 60.0, &[0]).with_window(100.0, 1_000.0), + task(1, 60.0, &[0]), + ]; + let report = detect(&schedule, &changed, &resources); + assert!(report.count_of(ConflictKind::Capacity) >= 1); + assert!(report.count_of(ConflictKind::Window) >= 1); + assert!(report.len() >= 2); +} + +#[test] +fn the_report_is_ordered_by_time() { + let resources = resources(); + // The placements are made inside a wide window, then the task list is + // changed so that both placements fall before the new window opens. The + // schedule refuses such a placement, which is why the changed list is + // used for the check. + let placed = [task(0, 60.0, &[0]), task(1, 60.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&placed[0], 100.0).expect("fits"); + schedule.place(&placed[1], 300.0).expect("fits"); + + let changed = vec![ + task(0, 60.0, &[0]).with_window(500.0, 1_000.0), + task(1, 60.0, &[1]).with_window(500.0, 1_000.0), + ]; + let report = detect(&schedule, &changed, &resources); + assert!(report.len() >= 2); + let times: Vec = report.conflicts().iter().map(|c| c.at).collect(); + let mut sorted = times.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).expect("finite")); + assert_eq!(times, sorted); +} + +#[test] +fn the_report_lists_the_conflicting_tasks_once_each() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + let tasks_involved = report.conflicting_tasks(); + // Both tasks are involved, and each appears once. + assert!(tasks_involved.contains(&TaskId::new(0))); + assert!(tasks_involved.contains(&TaskId::new(1))); + assert_eq!(tasks_involved.len(), 2); +} + +#[test] +fn the_counts_by_kind_sum_to_the_total() { + let resources = resources(); + let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + + let report = detect(&schedule, &tasks, &resources); + let summed = report.count_of(ConflictKind::Capacity) + + report.count_of(ConflictKind::Window) + + report.count_of(ConflictKind::Requirement) + + report.count_of(ConflictKind::Availability) + + report.count_of(ConflictKind::Precedence); + assert_eq!(summed, report.len()); +} + +#[test] +fn a_precedence_violation_is_found() { + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 100.0).expect("fits"); + // The second task must follow the first, but starts inside it. + schedule.place(&tasks[1], 120.0).expect("fits"); + + let conflicts = detect_precedence( + &schedule, + &[Precedence::new(TaskId::new(0), TaskId::new(1))], + ); + assert_eq!(conflicts.len(), 1); + let conflict = &conflicts[0]; + assert_eq!(conflict.kind, ConflictKind::Precedence); + assert_eq!(conflict.task, TaskId::new(1)); + assert_eq!(conflict.other, Some(TaskId::new(0))); + assert_eq!(conflict.at, 120.0); +} + +#[test] +fn a_satisfied_precedence_has_no_conflict() { + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 100.0).expect("fits"); + // Starting exactly at the end of the first is allowed, because the + // placement interval is half-open. + schedule.place(&tasks[1], 160.0).expect("fits"); + assert!( + detect_precedence( + &schedule, + &[Precedence::new(TaskId::new(0), TaskId::new(1))] + ) + .is_empty() + ); +} + +#[test] +fn a_precedence_over_an_unplaced_task_is_ignored() { + let tasks = [task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 100.0).expect("fits"); + // The second task is not placed, so nothing has been promised. + assert!( + detect_precedence( + &schedule, + &[Precedence::new(TaskId::new(0), TaskId::new(1))] + ) + .is_empty() + ); + assert!( + detect_precedence( + &schedule, + &[Precedence::new(TaskId::new(1), TaskId::new(0))] + ) + .is_empty() + ); +} + +#[test] +fn a_conflict_displays_its_parts() { + let conflict = Conflict::new( + ConflictKind::Capacity, + TaskId::new(0), + 30.0, + "over committed", + ) + .with_other(TaskId::new(1)) + .with_resource(ResourceId::new(2)); + let text = conflict.to_string(); + assert!(text.contains("capacity"), "{text}"); + assert!(text.contains("task 0"), "{text}"); + assert!(text.contains("task 1"), "{text}"); + assert!(text.contains("resource 2"), "{text}"); + assert!(text.contains("30"), "{text}"); +} + +#[test] +fn the_conflict_kinds_display_their_names() { + assert_eq!(ConflictKind::Capacity.to_string(), "capacity"); + assert_eq!(ConflictKind::Window.to_string(), "window"); + assert_eq!(ConflictKind::Requirement.to_string(), "requirement"); + assert_eq!(ConflictKind::Availability.to_string(), "availability"); + assert_eq!(ConflictKind::Precedence.to_string(), "precedence"); +} + +#[test] +fn movability_is_reported_by_kind() { + assert!(Conflict::new(ConflictKind::Capacity, TaskId::new(0), 0.0, "").is_movable()); + assert!(Conflict::new(ConflictKind::Availability, TaskId::new(0), 0.0, "").is_movable()); + assert!(!Conflict::new(ConflictKind::Window, TaskId::new(0), 0.0, "").is_movable()); + assert!(!Conflict::new(ConflictKind::Requirement, TaskId::new(0), 0.0, "").is_movable()); + assert!(!Conflict::new(ConflictKind::Precedence, TaskId::new(0), 0.0, "").is_movable()); +} + +#[test] +fn the_report_can_be_built_directly() { + use apsis_conflict::ConflictReport; + let report = ConflictReport::new( + vec![Conflict::new( + ConflictKind::Window, + TaskId::new(0), + 5.0, + "late", + )], + 3, + 2, + ); + assert_eq!(report.len(), 1); + assert_eq!(report.offered_tasks(), 3); + assert_eq!(report.placed_tasks(), 2); + assert!(report.summary().contains("1 conflict")); +}