From 305db011407068166fd63da8d168021cfa364d4a Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 17:02:35 +0800 Subject: [PATCH] ops: add schedule representation and evaluation --- Cargo.lock | 5 + crates/ops/apsis-schedule/Cargo.toml | 3 + crates/ops/apsis-schedule/src/error.rs | 79 ++++ crates/ops/apsis-schedule/src/lib.rs | 45 ++ crates/ops/apsis-schedule/src/placement.rs | 133 ++++++ crates/ops/apsis-schedule/src/schedule.rs | 405 ++++++++++++++++ crates/ops/apsis-schedule/tests/schedule.rs | 489 ++++++++++++++++++++ 7 files changed, 1159 insertions(+) create mode 100644 crates/ops/apsis-schedule/src/error.rs create mode 100644 crates/ops/apsis-schedule/src/placement.rs create mode 100644 crates/ops/apsis-schedule/src/schedule.rs create mode 100644 crates/ops/apsis-schedule/tests/schedule.rs diff --git a/Cargo.lock b/Cargo.lock index 79ecfef..97e1711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,6 +266,11 @@ version = "0.1.0" [[package]] name = "apsis-schedule" version = "0.1.0" +dependencies = [ + "apsis-core", + "apsis-resource", + "apsis-task", +] [[package]] name = "apsis-scheduler" diff --git a/crates/ops/apsis-schedule/Cargo.toml b/crates/ops/apsis-schedule/Cargo.toml index 7329e8b..ad7e3b2 100644 --- a/crates/ops/apsis-schedule/Cargo.toml +++ b/crates/ops/apsis-schedule/Cargo.toml @@ -11,3 +11,6 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-resource.workspace = true +apsis-task.workspace = true diff --git a/crates/ops/apsis-schedule/src/error.rs b/crates/ops/apsis-schedule/src/error.rs new file mode 100644 index 0000000..4838a47 --- /dev/null +++ b/crates/ops/apsis-schedule/src/error.rs @@ -0,0 +1,79 @@ +//! Errors reported while building or evaluating a schedule. + +use core::fmt; + +/// The category of a schedule error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ScheduleErrorKind { + /// The start time is not finite. + InvalidStart, + /// The task is already placed. + AlreadyPlaced, + /// The task is not placed. + NotPlaced, + /// The placement starts before the task's window opens. + BeforeWindow, + /// The placement ends after the task's window closes. + AfterWindow, + /// The placement does not use the resources the task requires. + RequirementMismatch, + /// A resource is used beyond its capacity. + CapacityExceeded, +} + +impl fmt::Display for ScheduleErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::InvalidStart => "the start time is not usable", + Self::AlreadyPlaced => "the task is already placed", + Self::NotPlaced => "the task is not placed", + Self::BeforeWindow => "the placement starts before the task window", + Self::AfterWindow => "the placement ends after the task window", + Self::RequirementMismatch => "the placement does not match the task requirements", + Self::CapacityExceeded => "a resource is used beyond its capacity", + }; + f.write_str(text) + } +} + +/// A failure while building or evaluating a schedule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScheduleError { + kind: ScheduleErrorKind, + detail: Option, +} + +impl ScheduleError { + /// Creates an error with the given category. + pub fn new(kind: ScheduleErrorKind) -> Self { + Self { kind, detail: None } + } + + /// Attaches a description of the values that caused the failure. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the category of this failure. + pub fn kind(&self) -> ScheduleErrorKind { + self.kind + } + + /// Returns the detail text attached to this error, if any. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } +} + +impl fmt::Display for ScheduleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.detail { + Some(detail) => write!(f, "{}: {detail}", self.kind), + None => write!(f, "{}", self.kind), + } + } +} + +impl core::error::Error for ScheduleError {} diff --git a/crates/ops/apsis-schedule/src/lib.rs b/crates/ops/apsis-schedule/src/lib.rs index a443c25..6f212a0 100644 --- a/crates/ops/apsis-schedule/src/lib.rs +++ b/crates/ops/apsis-schedule/src/lib.rs @@ -1 +1,46 @@ //! Schedule representation and evaluation. +//! +//! A schedule is the answer a scheduler produces: for each task, when it runs +//! and on which resources. This crate holds that answer and evaluates it. +//! It does not decide the placements, which is the job of `apsis-scheduler`; +//! keeping the two apart lets a schedule be read from a file, produced by a +//! scheduler, or written by hand, and evaluated the same way. +//! +//! A placement records the task, its start time, and the resources it holds. +//! The resources are checked against the task's requirements when the +//! placement is made, so a schedule cannot describe a task running on a +//! resource it did not ask for. +//! +//! The evaluation reports what an operator asks of a finished plan: how many +//! tasks were placed, how much of each resource is used at its busiest, and +//! how much of the available time was actually spent. Those figures are what +//! a planner compares between candidate schedules. +//! +//! ``` +//! use apsis_resource::ResourceId; +//! use apsis_schedule::Schedule; +//! use apsis_task::{Task, TaskId}; +//! +//! let task = Task::new(TaskId::new(0), 60.0) +//! .try_require(ResourceId::new(0), 1.0) +//! .expect("the resource is not yet required"); +//! +//! let mut schedule = Schedule::new(); +//! schedule.place(&task, 100.0).expect("the task fits"); +//! assert_eq!(schedule.len(), 1); +//! assert!(schedule.is_placed(TaskId::new(0))); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod error; +mod placement; +mod schedule; + +pub use error::{ScheduleError, ScheduleErrorKind}; +pub use placement::{Evaluation, Placement, ResourceUsage}; +pub use schedule::Schedule; + +pub use apsis_resource::ResourceId; +pub use apsis_task::{Task, TaskId}; diff --git a/crates/ops/apsis-schedule/src/placement.rs b/crates/ops/apsis-schedule/src/placement.rs new file mode 100644 index 0000000..91cb981 --- /dev/null +++ b/crates/ops/apsis-schedule/src/placement.rs @@ -0,0 +1,133 @@ +//! Placements and the evaluation of a schedule. + +use apsis_resource::ResourceId; +use apsis_task::TaskId; + +/// One task placed on the schedule. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Placement { + /// The task placed. + pub task: TaskId, + /// The start time of the placement, in seconds. + pub start: f64, + /// The duration of the placement, in seconds. + /// + /// This is copied from the task so that a schedule carries everything + /// needed to evaluate it without the tasks to hand. + pub duration: f64, +} + +impl Placement { + /// Creates a placement. + pub const fn new(task: TaskId, start: f64, duration: f64) -> Self { + Self { + task, + start, + duration, + } + } + + /// Returns the end time of the placement, in seconds. + pub fn end(&self) -> f64 { + self.start + self.duration + } + + /// Returns `true` when the placement covers an instant. + /// + /// The interval is half-open, so a placement covers its start and not its + /// end. That convention lets two back to back placements share a boundary + /// without overlapping. + pub fn covers(&self, time: f64) -> bool { + time >= self.start && time < self.end() + } + + /// Returns `true` when the placement overlaps another. + pub fn overlaps(&self, other: &Self) -> bool { + self.start < other.end() && other.start < self.end() + } + + /// Returns `true` when the placement is usable. + pub fn is_valid(&self) -> bool { + self.start.is_finite() && self.duration.is_finite() && self.duration >= 0.0 + } +} + +/// How much of one resource a schedule uses. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResourceUsage { + /// The resource. + pub resource: ResourceId, + /// The greatest total demand placed on the resource at any instant. + pub peak_demand: f64, + /// The time during which the resource is used at all, in seconds. + pub busy_seconds: f64, + /// The number of placements that use the resource. + pub placement_count: usize, +} + +impl ResourceUsage { + /// Returns the utilisation of the resource over an interval. + /// + /// The value is the busy time divided by the length of the interval. It + /// is a time utilisation, not a capacity utilisation: a resource that is + /// only half occupied by demand still counts as busy. + /// + /// Returns `None` when the interval is not positive. + pub fn utilisation(&self, interval_seconds: f64) -> Option { + if interval_seconds > 0.0 && interval_seconds.is_finite() { + Some(self.busy_seconds / interval_seconds) + } else { + None + } + } +} + +/// The evaluation of a whole schedule. +#[derive(Debug, Clone, PartialEq)] +pub struct Evaluation { + /// The number of placements. + pub placed_count: usize, + /// The number of tasks that were offered to the schedule. + pub offered_count: usize, + /// The sum of the placed durations, in seconds. + pub scheduled_seconds: f64, + /// The earliest start time, or zero when nothing is placed. + pub makespan_start: f64, + /// The latest end time, or zero when nothing is placed. + pub makespan_end: f64, + /// The usage of each resource, in identifier order. + pub resource_usage: Vec, +} + +impl Evaluation { + /// Returns the length of the planning horizon the schedule covers, in + /// seconds. + /// + /// This is the time from the earliest start to the latest end, which is + /// the makespan of the plan. + pub fn makespan(&self) -> f64 { + (self.makespan_end - self.makespan_start).max(0.0) + } + + /// Returns the fraction of the offered tasks that were placed. + /// + /// Returns `None` when no tasks were offered. + pub fn fill_rate(&self) -> Option { + if self.offered_count == 0 { + return None; + } + Some(self.placed_count as f64 / self.offered_count as f64) + } + + /// Returns `true` when every offered task was placed. + pub fn is_complete(&self) -> bool { + self.placed_count == self.offered_count + } + + /// Returns the usage of one resource. + pub fn usage_of(&self, resource: ResourceId) -> Option<&ResourceUsage> { + self.resource_usage + .iter() + .find(|usage| usage.resource == resource) + } +} diff --git a/crates/ops/apsis-schedule/src/schedule.rs b/crates/ops/apsis-schedule/src/schedule.rs new file mode 100644 index 0000000..3c3d709 --- /dev/null +++ b/crates/ops/apsis-schedule/src/schedule.rs @@ -0,0 +1,405 @@ +//! The schedule itself. + +use crate::error::{ScheduleError, ScheduleErrorKind}; +use crate::placement::{Evaluation, Placement, ResourceUsage}; +use apsis_resource::ResourceId; +use apsis_task::TaskId; +use std::collections::BTreeMap; + +/// A set of task placements. +/// +/// The schedule stores placements in the order they were made and keeps an +/// index from task to placement, so that a lookup by task is direct. A task +/// may be placed at most once: placing it again is an error rather than a +/// silent overwrite, because a schedule that contains two placements for one +/// task is not a schedule. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Schedule { + placements: Vec, + index: BTreeMap, +} + +impl Schedule { + /// Creates an empty schedule. + pub fn new() -> Self { + Self { + placements: Vec::new(), + index: BTreeMap::new(), + } + } + + /// Returns the placements in the order they were made. + pub fn placements(&self) -> &[Placement] { + &self.placements + } + + /// Returns the number of placements. + pub fn len(&self) -> usize { + self.placements.len() + } + + /// Returns `true` when there are no placements. + pub fn is_empty(&self) -> bool { + self.placements.is_empty() + } + + /// Returns `true` when a task has been placed. + pub fn is_placed(&self, task: TaskId) -> bool { + self.index.contains_key(&task.index()) + } + + /// Returns the placement of a task. + pub fn placement_of(&self, task: TaskId) -> Option<&Placement> { + self.index + .get(&task.index()) + .map(|position| &self.placements[*position]) + } + + /// Places a task at a time. + /// + /// The placement is checked against the task's window. The resources of + /// the task are recorded with the placement through the task itself, so + /// this method does not need them; the capacity check is performed by + /// [`Schedule::validate_capacity`], which needs the resources. + /// + /// Returns an error when the task is already placed, when the start time + /// is not finite, or when the placement falls outside the task's window. + pub fn place( + &mut self, + task: &apsis_task::Task, + start: f64, + ) -> Result<&Placement, ScheduleError> { + if !start.is_finite() { + return Err(ScheduleError::new(ScheduleErrorKind::InvalidStart) + .with_detail(format!("start {start}"))); + } + if self.is_placed(task.id) { + return Err(ScheduleError::new(ScheduleErrorKind::AlreadyPlaced) + .with_detail(format!("{}", task.id))); + } + if start < task.earliest_start { + return Err( + ScheduleError::new(ScheduleErrorKind::BeforeWindow).with_detail(format!( + "task {} starts at {start}, before its window opens at {}", + task.id, task.earliest_start + )), + ); + } + if start + task.duration > task.latest_end { + return Err( + ScheduleError::new(ScheduleErrorKind::AfterWindow).with_detail(format!( + "task {} ends at {}, after its window closes at {}", + task.id, + start + task.duration, + task.latest_end + )), + ); + } + let placement = Placement::new(task.id, start, task.duration); + self.index.insert(task.id.index(), self.placements.len()); + self.placements.push(placement); + Ok(self + .placements + .last() + .expect("the placement was just pushed")) + } + + /// Removes the placement of a task. + /// + /// Returns an error when the task is not placed. + pub fn unplace(&mut self, task: TaskId) -> Result { + let position = self.index.remove(&task.index()).ok_or_else(|| { + ScheduleError::new(ScheduleErrorKind::NotPlaced).with_detail(format!("{task}")) + })?; + let removed = self.placements.remove(position); + // Every placement after the removed one shifts down by one, so the + // index must be rebuilt. The schedule is small enough that this is + // cheaper than maintaining a linked structure. + self.rebuild_index(); + Ok(removed) + } + + /// Rebuilds the task to position index. + fn rebuild_index(&mut self) { + self.index.clear(); + for (position, placement) in self.placements.iter().enumerate() { + self.index.insert(placement.task.index(), position); + } + } + + /// Returns the placements that use a resource, in time order. + pub fn placements_on( + &self, + resource: ResourceId, + tasks: &[apsis_task::Task], + ) -> Vec { + let mut selected: Vec = self + .placements + .iter() + .filter(|placement| { + tasks + .iter() + .find(|task| task.id == placement.task) + .is_some_and(|task| task.requires(resource)) + }) + .copied() + .collect(); + selected.sort_by(|first, second| { + first + .start + .partial_cmp(&second.start) + .unwrap_or(core::cmp::Ordering::Equal) + }); + selected + } + + /// Returns `true` when two placements of different tasks overlap. + /// + /// This checks the schedule against itself, ignoring the resources. Two + /// tasks on disjoint resources may legitimately overlap, so a caller that + /// wants to know whether a resource is overbooked should use + /// [`Schedule::validate_capacity`] instead. + pub fn has_overlapping_placements(&self) -> bool { + for (index, placement) in self.placements.iter().enumerate() { + if self.placements[..index] + .iter() + .any(|earlier| earlier.overlaps(placement)) + { + return true; + } + } + false + } + + /// Returns the total placed duration, in seconds. + pub fn scheduled_seconds(&self) -> f64 { + self.placements + .iter() + .map(|placement| placement.duration) + .sum() + } + + /// Returns the earliest start of any placement. + /// + /// Returns `None` when the schedule is empty. + pub fn earliest_start(&self) -> Option { + self.placements + .iter() + .map(|placement| placement.start) + .min_by(|first, second| { + first + .partial_cmp(second) + .unwrap_or(core::cmp::Ordering::Equal) + }) + } + + /// Returns the latest end of any placement. + /// + /// Returns `None` when the schedule is empty. + pub fn latest_end(&self) -> Option { + self.placements + .iter() + .map(Placement::end) + .max_by(|first, second| { + first + .partial_cmp(second) + .unwrap_or(core::cmp::Ordering::Equal) + }) + } + + /// Validates a schedule against a set of resources. + /// + /// The check has two parts. First every placement must agree with the + /// requirements of its task, which catches a schedule built with a + /// different task list. Then, for each resource, the total demand placed + /// at any instant must not exceed the capacity. + /// + /// The second check walks the placement boundaries of each resource in + /// time order, which is exact because the total demand is constant + /// between boundaries. + /// + /// Returns an error describing the first violation. + pub fn validate_capacity( + &self, + tasks: &[apsis_task::Task], + resources: &apsis_resource::ResourceSet, + ) -> Result<(), ScheduleError> { + for placement in &self.placements { + let task = tasks + .iter() + .find(|task| task.id == placement.task) + .ok_or_else(|| { + ScheduleError::new(ScheduleErrorKind::RequirementMismatch) + .with_detail(format!("{} is not in the task list", placement.task)) + })?; + if (placement.duration - task.duration).abs() > 1e-12 { + return Err(ScheduleError::new(ScheduleErrorKind::RequirementMismatch) + .with_detail(format!( + "{} is placed for {} s but takes {} s", + task.id, placement.duration, task.duration + ))); + } + } + + for resource in resources.resources() { + // The boundaries at which the total demand can change. + let mut events: Vec<(f64, f64)> = Vec::new(); + for placement in &self.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)); + events.push((placement.end(), -demand)); + } + } + if events.is_empty() { + continue; + } + // Sort by time, with a release before an acquisition at the same + // instant so that 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) + }) + }); + + let mut current = 0.0; + for (time, change) in events { + current += change; + if current > resource.capacity() + 1e-9 { + return Err(ScheduleError::new(ScheduleErrorKind::CapacityExceeded) + .with_detail(format!( + "{} carries demand {} against capacity {} at {time}", + resource.id, + current, + resource.capacity() + ))); + } + } + } + Ok(()) + } + + /// Evaluates the schedule against a task list and a resource set. + /// + /// The evaluation reports the counts, the makespan, and the usage of each + /// resource. The peak demand of a resource is found by the same event + /// sweep that the capacity check uses, so the two agree. + pub fn evaluate( + &self, + tasks: &[apsis_task::Task], + resources: &apsis_resource::ResourceSet, + ) -> Evaluation { + let mut resource_usage = Vec::with_capacity(resources.len()); + for resource in resources.resources() { + let mut events: Vec<(f64, f64)> = Vec::new(); + let mut placement_count = 0; + for placement in &self.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)); + events.push((placement.end(), -demand)); + placement_count += 1; + } + } + 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) + }) + }); + + let mut current = 0.0; + let mut peak = 0.0_f64; + let mut busy = 0.0_f64; + let mut previous: Option = None; + for (time, change) in events { + if let Some(previous) = previous { + if current > 0.0 { + busy += time - previous; + } + } + current += change; + peak = peak.max(current); + previous = Some(time); + } + + resource_usage.push(ResourceUsage { + resource: resource.id, + peak_demand: peak, + busy_seconds: busy, + placement_count, + }); + } + + Evaluation { + placed_count: self.placements.len(), + offered_count: tasks.len(), + scheduled_seconds: self.scheduled_seconds(), + makespan_start: self.earliest_start().unwrap_or(0.0), + makespan_end: self.latest_end().unwrap_or(0.0), + resource_usage, + } + } + + /// Returns the schedule sorted by start time, leaving the original alone. + /// + /// A schedule is stored in the order it was built, which is the order a + /// scheduler considered the tasks. A caller that wants to read the plan + /// in time order uses this. + pub fn in_time_order(&self) -> Vec { + let mut ordered = self.placements.clone(); + ordered.sort_by(|first, second| { + first + .start + .partial_cmp(&second.start) + .unwrap_or(core::cmp::Ordering::Equal) + .then_with(|| first.task.cmp(&second.task)) + }); + ordered + } + + /// Returns the time at which a resource becomes free if it is busy at a + /// given instant. + /// + /// Returns `None` when the resource is idle there. This is what a + /// sequential scheduler asks when it looks for the next gap. + pub fn resource_busy_until( + &self, + resource: ResourceId, + tasks: &[apsis_task::Task], + time: f64, + ) -> Option { + self.placements + .iter() + .filter(|placement| { + tasks + .iter() + .find(|task| task.id == placement.task) + .is_some_and(|task| task.requires(resource) && placement.covers(time)) + }) + .map(Placement::end) + .max_by(|first, second| { + first + .partial_cmp(second) + .unwrap_or(core::cmp::Ordering::Equal) + }) + } +} diff --git a/crates/ops/apsis-schedule/tests/schedule.rs b/crates/ops/apsis-schedule/tests/schedule.rs new file mode 100644 index 0000000..b8b858f --- /dev/null +++ b/crates/ops/apsis-schedule/tests/schedule.rs @@ -0,0 +1,489 @@ +//! Tests for the schedule representation and its evaluation. + +use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +use apsis_schedule::{Evaluation, Placement, Schedule, ScheduleErrorKind}; +use apsis_task::{Task, TaskId}; + +/// A resource with the given identifier 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 the given resources. +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 +} + +#[test] +fn a_new_schedule_is_empty() { + let schedule = Schedule::new(); + assert!(schedule.is_empty()); + assert_eq!(schedule.len(), 0); + assert!(schedule.placements().is_empty()); + assert_eq!(schedule.earliest_start(), None); + assert_eq!(schedule.latest_end(), None); + assert_eq!(schedule.scheduled_seconds(), 0.0); +} + +#[test] +fn placing_a_task_records_it() { + let mut schedule = Schedule::new(); + let task = task(0, 60.0, &[0]); + schedule.place(&task, 100.0).expect("the task fits"); + + assert_eq!(schedule.len(), 1); + assert!(schedule.is_placed(TaskId::new(0))); + let placement = schedule.placement_of(TaskId::new(0)).expect("placed"); + assert_eq!(placement.task, TaskId::new(0)); + assert_eq!(placement.start, 100.0); + assert_eq!(placement.duration, 60.0); + assert_eq!(placement.end(), 160.0); + assert_eq!(schedule.scheduled_seconds(), 60.0); + assert_eq!(schedule.earliest_start(), Some(100.0)); + assert_eq!(schedule.latest_end(), Some(160.0)); +} + +#[test] +fn a_task_can_be_placed_only_once() { + let mut schedule = Schedule::new(); + let task = task(0, 60.0, &[0]); + schedule.place(&task, 100.0).expect("the task fits"); + let error = schedule + .place(&task, 200.0) + .expect_err("the task is already placed"); + assert_eq!(error.kind(), ScheduleErrorKind::AlreadyPlaced); + assert_eq!(schedule.len(), 1); +} + +#[test] +fn a_placement_before_the_window_is_rejected() { + let mut schedule = Schedule::new(); + let task = task(0, 60.0, &[0]).with_window(100.0, 1_000.0); + let error = schedule + .place(&task, 50.0) + .expect_err("the placement is too early"); + assert_eq!(error.kind(), ScheduleErrorKind::BeforeWindow); + assert!(schedule.is_empty()); +} + +#[test] +fn a_placement_after_the_window_is_rejected() { + let mut schedule = Schedule::new(); + let task = task(0, 60.0, &[0]).with_window(0.0, 100.0); + // Starting at fifty would end at one hundred and ten, past the window. + let error = schedule + .place(&task, 50.0) + .expect_err("the placement is too late"); + assert_eq!(error.kind(), ScheduleErrorKind::AfterWindow); + // Starting at forty ends exactly at the window close, which is allowed. + assert!(schedule.place(&task, 40.0).is_ok()); +} + +#[test] +fn a_non_finite_start_is_rejected() { + let mut schedule = Schedule::new(); + let task = task(0, 60.0, &[0]); + for start in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let error = schedule + .place(&task, start) + .expect_err("the start is not finite"); + assert_eq!( + error.kind(), + ScheduleErrorKind::InvalidStart, + "start {start}" + ); + } + assert!(schedule.is_empty()); +} + +#[test] +fn a_task_can_be_unplaced() { + let mut schedule = Schedule::new(); + let first = task(0, 60.0, &[0]); + let second = task(1, 30.0, &[0]); + schedule.place(&first, 0.0).expect("fits"); + schedule.place(&second, 100.0).expect("fits"); + assert_eq!(schedule.len(), 2); + + let removed = schedule.unplace(TaskId::new(0)).expect("placed"); + assert_eq!(removed.task, TaskId::new(0)); + assert_eq!(schedule.len(), 1); + assert!(!schedule.is_placed(TaskId::new(0))); + // The remaining placement is still found, so the index was rebuilt. + assert!(schedule.is_placed(TaskId::new(1))); + assert_eq!( + schedule + .placement_of(TaskId::new(1)) + .expect("still placed") + .start, + 100.0 + ); + + assert_eq!( + schedule.unplace(TaskId::new(0)).expect_err("gone").kind(), + ScheduleErrorKind::NotPlaced + ); +} + +#[test] +fn the_index_survives_several_removals() { + let mut schedule = Schedule::new(); + let tasks: Vec = (0..5).map(|index| task(index, 10.0, &[index])).collect(); + for (index, task) in tasks.iter().enumerate() { + schedule + .place(task, index as f64 * 100.0) + .expect("the task fits"); + } + // Remove from the middle, which shifts every later placement. + schedule.unplace(TaskId::new(2)).expect("placed"); + for index in [0_usize, 1, 3, 4] { + let placement = schedule + .placement_of(TaskId::new(index)) + .unwrap_or_else(|| panic!("task {index} should still be placed")); + assert_eq!(placement.start, index as f64 * 100.0); + } + assert_eq!(schedule.len(), 4); +} + +#[test] +fn placements_on_a_resource_are_found_in_time_order() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]) + .expect("the identifiers are distinct"); + let tasks = vec![ + task(0, 60.0, &[0]), + task(1, 30.0, &[1]), + task(2, 20.0, &[0]), + ]; + + let mut schedule = Schedule::new(); + // Placed out of time order. + schedule.place(&tasks[2], 200.0).expect("fits"); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 100.0).expect("fits"); + + let on_zero = schedule.placements_on(ResourceId::new(0), &tasks); + assert_eq!(on_zero.len(), 2); + assert_eq!(on_zero[0].task, TaskId::new(0)); + assert_eq!(on_zero[1].task, TaskId::new(2)); + + let on_one = schedule.placements_on(ResourceId::new(1), &tasks); + assert_eq!(on_one.len(), 1); + assert_eq!(on_one[0].task, TaskId::new(1)); + + assert!( + schedule + .placements_on(ResourceId::new(9), &tasks) + .is_empty() + ); + let _ = resources; +} + +#[test] +fn overlapping_placements_are_detected() { + let mut schedule = Schedule::new(); + let first = task(0, 60.0, &[0]); + let second = task(1, 60.0, &[1]); + schedule.place(&first, 0.0).expect("fits"); + schedule.place(&second, 30.0).expect("fits"); + // The two overlap in time, although they use different resources, so the + // schedule reports an overlap and the capacity check allows it. + assert!(schedule.has_overlapping_placements()); +} + +#[test] +fn back_to_back_placements_do_not_overlap() { + let mut schedule = Schedule::new(); + let first = task(0, 60.0, &[0]); + let second = task(1, 60.0, &[0]); + schedule.place(&first, 0.0).expect("fits"); + schedule.place(&second, 60.0).expect("fits"); + assert!(!schedule.has_overlapping_placements()); +} + +#[test] +fn the_capacity_check_accepts_an_exclusive_resource_used_once() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + 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!(schedule.validate_capacity(&tasks, &resources).is_ok()); +} + +#[test] +fn the_capacity_check_rejects_an_exclusive_resource_used_twice() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + 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 error = schedule + .validate_capacity(&tasks, &resources) + .expect_err("the resource is overbooked"); + assert_eq!(error.kind(), ScheduleErrorKind::CapacityExceeded); +} + +#[test] +fn a_shared_resource_allows_two_tasks_within_the_capacity() { + // A resource of capacity two holds two unit tasks at once. + let resources = ResourceSet::from_resources(vec![resource(0, 2.0)]).expect("one"); + 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"); + assert!(schedule.validate_capacity(&tasks, &resources).is_ok()); + + // A third would exceed it. + let third = task(2, 60.0, &[0]); + schedule.place(&third, 45.0).expect("fits"); + let mut all = tasks.clone(); + all.push(third); + assert_eq!( + schedule + .validate_capacity(&all, &resources) + .expect_err("three exceeds two") + .kind(), + ScheduleErrorKind::CapacityExceeded + ); +} + +#[test] +fn fractional_demands_share_a_resource() { + // Four tasks of a quarter unit each fill a resource of capacity one. + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks: Vec = (0..4) + .map(|index| { + Task::new(TaskId::new(index), 60.0) + .try_require(ResourceId::new(0), 0.25) + .expect("the resource is new") + }) + .collect(); + let mut schedule = Schedule::new(); + for task in &tasks { + schedule.place(task, 0.0).expect("fits"); + } + assert!(schedule.validate_capacity(&tasks, &resources).is_ok()); + + let fifth = Task::new(TaskId::new(9), 60.0) + .try_require(ResourceId::new(0), 0.25) + .expect("the resource is new"); + schedule.place(&fifth, 0.0).expect("fits"); + let mut all = tasks.clone(); + all.push(fifth); + assert_eq!( + schedule + .validate_capacity(&all, &resources) + .expect_err("five quarters exceeds one") + .kind(), + ScheduleErrorKind::CapacityExceeded + ); +} + +#[test] +fn the_capacity_check_rejects_a_placement_without_its_task() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 60.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + // The task list does not contain the placed task. + assert_eq!( + schedule + .validate_capacity(&[], &resources) + .expect_err("the task is missing") + .kind(), + ScheduleErrorKind::RequirementMismatch + ); +} + +#[test] +fn the_capacity_check_rejects_a_duration_mismatch() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let original = task(0, 60.0, &[0]); + let mut schedule = Schedule::new(); + schedule.place(&original, 0.0).expect("fits"); + // A task with the same identifier but a different duration. + let different = task(0, 90.0, &[0]); + assert_eq!( + schedule + .validate_capacity(&[different], &resources) + .expect_err("the durations differ") + .kind(), + ScheduleErrorKind::RequirementMismatch + ); +} + +#[test] +fn the_evaluation_reports_the_counts_and_the_makespan() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = vec![task(0, 60.0, &[0]), task(1, 30.0, &[0])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 100.0).expect("fits"); + + let evaluation = schedule.evaluate(&tasks, &resources); + assert_eq!(evaluation.placed_count, 2); + assert_eq!(evaluation.offered_count, 2); + assert_eq!(evaluation.scheduled_seconds, 90.0); + assert_eq!(evaluation.makespan_start, 0.0); + assert_eq!(evaluation.makespan_end, 130.0); + assert_eq!(evaluation.makespan(), 130.0); + assert_eq!(evaluation.fill_rate(), Some(1.0)); + assert!(evaluation.is_complete()); +} + +#[test] +fn the_evaluation_reports_resource_usage() { + let resources = + ResourceSet::from_resources(vec![resource(0, 2.0), resource(1, 1.0)]).expect("distinct"); + let tasks = vec![ + task(0, 60.0, &[0]), + task(1, 60.0, &[0]), + task(2, 30.0, &[1]), + ]; + let mut schedule = Schedule::new(); + // The two on resource zero overlap, so the peak demand is two. + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + schedule.place(&tasks[2], 0.0).expect("fits"); + + let evaluation = schedule.evaluate(&tasks, &resources); + let zero = evaluation + .usage_of(ResourceId::new(0)) + .expect("the resource is used"); + assert_eq!(zero.peak_demand, 2.0); + // The two overlap for thirty seconds, so the busy time is ninety. + assert_eq!(zero.busy_seconds, 90.0); + assert_eq!(zero.placement_count, 2); + assert_eq!(zero.utilisation(180.0), Some(0.5)); + + let one = evaluation + .usage_of(ResourceId::new(1)) + .expect("the resource is used"); + assert_eq!(one.peak_demand, 1.0); + assert_eq!(one.busy_seconds, 30.0); + assert_eq!(one.placement_count, 1); + + assert!(evaluation.usage_of(ResourceId::new(9)).is_none()); +} + +#[test] +fn the_evaluation_of_an_empty_schedule_is_zero() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let schedule = Schedule::new(); + let evaluation = schedule.evaluate(&[], &resources); + assert_eq!(evaluation.placed_count, 0); + assert_eq!(evaluation.offered_count, 0); + assert_eq!(evaluation.makespan(), 0.0); + // With nothing offered and nothing placed, the schedule is vacuously + // complete, but there is no fill rate to report. + assert_eq!(evaluation.fill_rate(), None); + assert!(evaluation.is_complete()); + let zero = evaluation.usage_of(ResourceId::new(0)).expect("present"); + assert_eq!(zero.peak_demand, 0.0); + assert_eq!(zero.busy_seconds, 0.0); + assert_eq!(zero.utilisation(0.0), None); +} + +#[test] +fn the_fill_rate_reports_unplaced_tasks() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + 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"); + + let evaluation = schedule.evaluate(&tasks, &resources); + assert_eq!(evaluation.placed_count, 1); + assert_eq!(evaluation.offered_count, 2); + assert_eq!(evaluation.fill_rate(), Some(0.5)); + assert!(!evaluation.is_complete()); +} + +#[test] +fn the_time_order_differs_from_the_insertion_order() { + let mut schedule = Schedule::new(); + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + schedule.place(&tasks[0], 200.0).expect("fits"); + schedule.place(&tasks[1], 0.0).expect("fits"); + + // The insertion order is 0 then 1. + assert_eq!(schedule.placements()[0].task, TaskId::new(0)); + // The time order is 1 then 0. + let ordered = schedule.in_time_order(); + assert_eq!(ordered[0].task, TaskId::new(1)); + assert_eq!(ordered[1].task, TaskId::new(0)); + // The original is unchanged. + assert_eq!(schedule.placements()[0].task, TaskId::new(0)); +} + +#[test] +fn the_resource_busy_until_reports_the_next_free_time() { + let tasks = vec![task(0, 60.0, &[0]), task(1, 60.0, &[1])]; + let mut schedule = Schedule::new(); + schedule.place(&tasks[0], 0.0).expect("fits"); + schedule.place(&tasks[1], 30.0).expect("fits"); + + assert_eq!( + schedule.resource_busy_until(ResourceId::new(0), &tasks, 30.0), + Some(60.0) + ); + // Resource zero is idle at a hundred seconds. + assert_eq!( + schedule.resource_busy_until(ResourceId::new(0), &tasks, 100.0), + None + ); + // Resource one is busy at thirty seconds and free at ninety. + assert_eq!( + schedule.resource_busy_until(ResourceId::new(1), &tasks, 50.0), + Some(90.0) + ); +} + +#[test] +fn a_placement_reports_what_it_covers() { + let placement = Placement::new(TaskId::new(0), 100.0, 60.0); + assert_eq!(placement.end(), 160.0); + assert!(placement.covers(100.0)); + assert!(placement.covers(159.999)); + // The end is not covered, so back to back placements do not overlap. + assert!(!placement.covers(160.0)); + assert!(!placement.covers(99.999)); + assert!(placement.is_valid()); +} + +#[test] +fn placements_overlap_only_when_they_share_time() { + let first = Placement::new(TaskId::new(0), 0.0, 60.0); + let second = Placement::new(TaskId::new(1), 60.0, 60.0); + assert!(!first.overlaps(&second)); + let overlapping = Placement::new(TaskId::new(2), 30.0, 60.0); + assert!(first.overlaps(&overlapping)); + assert!(overlapping.overlaps(&first)); +} + +#[test] +fn an_invalid_placement_is_reported() { + assert!(!Placement::new(TaskId::new(0), f64::NAN, 60.0).is_valid()); + assert!(!Placement::new(TaskId::new(0), 0.0, -1.0).is_valid()); + // A zero duration placement is valid, for an instantaneous task. + assert!(Placement::new(TaskId::new(0), 0.0, 0.0).is_valid()); +} + +#[test] +fn the_evaluation_type_is_nameable() { + // The type must be usable by a caller that stores the results. + fn takes(_evaluation: &Evaluation) {} + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let schedule = Schedule::new(); + let evaluation = schedule.evaluate(&[], &resources); + takes(&evaluation); +}