From 6e1deec7509d6ffcee8c6a3324a75f6923d03166 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 22:21:20 +0800 Subject: [PATCH] ops: add precedence constraints and an improvement pass --- Cargo.lock | 1 + crates/apsis-rs/examples/integrate_ops.rs | 71 +++- crates/ops/apsis-scheduler/Cargo.toml | 3 + crates/ops/apsis-scheduler/src/improve.rs | 255 ++++++++++++++ crates/ops/apsis-scheduler/src/lib.rs | 4 +- crates/ops/apsis-scheduler/src/planner.rs | 317 +++++++++++++++--- crates/ops/apsis-scheduler/tests/planner.rs | 38 ++- .../ops/apsis-scheduler/tests/precedence.rs | 295 ++++++++++++++++ crates/ops/apsis-task/src/error.rs | 3 + crates/ops/apsis-task/src/lib.rs | 7 +- crates/ops/apsis-task/src/precedence.rs | 245 ++++++++++++++ crates/ops/apsis-task/tests/precedence.rs | 233 +++++++++++++ 12 files changed, 1421 insertions(+), 51 deletions(-) create mode 100644 crates/ops/apsis-scheduler/src/improve.rs create mode 100644 crates/ops/apsis-scheduler/tests/precedence.rs create mode 100644 crates/ops/apsis-task/src/precedence.rs create mode 100644 crates/ops/apsis-task/tests/precedence.rs diff --git a/Cargo.lock b/Cargo.lock index 613e3e0..3a1877c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -282,6 +282,7 @@ dependencies = [ name = "apsis-scheduler" version = "0.1.0" dependencies = [ + "apsis-conflict", "apsis-core", "apsis-resource", "apsis-schedule", diff --git a/crates/apsis-rs/examples/integrate_ops.rs b/crates/apsis-rs/examples/integrate_ops.rs index 84b9dc7..8c4c336 100644 --- a/crates/apsis-rs/examples/integrate_ops.rs +++ b/crates/apsis-rs/examples/integrate_ops.rs @@ -10,7 +10,8 @@ //! 1. Define a satellite and a ground station as resources, taking the //! station's availability from access windows. //! 2. Define observation tasks that each need the satellite and the station. -//! 3. Plan the tasks with `apsis-scheduler`. +//! 3. Plan the tasks with `apsis-scheduler`, using precedence constraints +//! to express a task that needs two resources in sequence. //! 4. Check the result with `apsis-conflict`. //! 5. Evaluate the schedule with `apsis-schedule`. //! @@ -23,8 +24,8 @@ use apsis_conflict::detect; use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; use apsis_schedule::Schedule; -use apsis_scheduler::Planner; -use apsis_task::{Task, TaskId}; +use apsis_scheduler::{ImprovementSettings, Planner}; +use apsis_task::{PrecedenceGraph, Task, TaskId}; /// The satellite resource. const SATELLITE: ResourceId = ResourceId::new(0); @@ -152,6 +153,70 @@ fn main() { let again = Planner::new().schedule(&tasks, &resources); assert_eq!(result.schedule, again.schedule); let _ = Schedule::new(); + let _ = ImprovementSettings::disabled(); + + // Step 6: a task that needs two resources in sequence. An observation + // uses the payload, and the downlink that follows uses the transmitter. + // The two are separate tasks joined by a precedence constraint, which is + // how a sequential resource use is expressed. + let payload = ResourceId::new(2); + let transmitter = ResourceId::new(3); + let mut extra = resources.clone(); + let mut payload_resource = Resource::new(payload, 1.0); + payload_resource + .set_windows(&[Window::new(0.0, 5_000.0)]) + .expect("the window is usable"); + let mut transmitter_resource = Resource::new(transmitter, 1.0); + transmitter_resource + .set_windows(&[Window::new(0.0, 5_000.0)]) + .expect("the window is usable"); + extra + .insert(payload_resource) + .expect("the identifier is new"); + extra + .insert(transmitter_resource) + .expect("the identifier is new"); + + let chain = [ + Task::new(TaskId::new(100), 120.0) + .with_priority(10.0) + .with_window(200.0, 1_500.0) + .try_require(payload, 1.0) + .expect("the payload is not yet required"), + Task::new(TaskId::new(101), 90.0) + .with_priority(10.0) + .with_window(0.0, 2_000.0) + .try_require(transmitter, 1.0) + .expect("the transmitter is not yet required"), + ]; + let mut graph = PrecedenceGraph::new(); + graph + .add(TaskId::new(100), TaskId::new(101)) + .expect("the constraint does not close a cycle"); + + let chained = Planner::new().schedule_with_precedence(&chain, &extra, &graph); + assert!(chained.is_complete(), "{:?}", chained.outcomes); + assert!(chained.honours_precedence(&graph)); + let collection = chained + .schedule + .placement_of(TaskId::new(100)) + .expect("the collection is placed"); + let transmission = chained + .schedule + .placement_of(TaskId::new(101)) + .expect("the transmission is placed"); + println!( + "sequence: collect {:.0}-{:.0} s, downlink {:.0}-{:.0} s", + collection.start, + collection.end(), + transmission.start, + transmission.end() + ); + assert!( + transmission.start >= collection.end(), + "the downlink must follow the collection" + ); + assert!(apsis_conflict::detect(&chained.schedule, &chain, &extra).is_clear()); println!("integration check passed"); } diff --git a/crates/ops/apsis-scheduler/Cargo.toml b/crates/ops/apsis-scheduler/Cargo.toml index 9b85519..dea6d72 100644 --- a/crates/ops/apsis-scheduler/Cargo.toml +++ b/crates/ops/apsis-scheduler/Cargo.toml @@ -15,3 +15,6 @@ apsis-core.workspace = true apsis-resource.workspace = true apsis-task.workspace = true apsis-schedule.workspace = true + +[dev-dependencies] +apsis-conflict.workspace = true diff --git a/crates/ops/apsis-scheduler/src/improve.rs b/crates/ops/apsis-scheduler/src/improve.rs new file mode 100644 index 0000000..4c477f3 --- /dev/null +++ b/crates/ops/apsis-scheduler/src/improve.rs @@ -0,0 +1,255 @@ +//! A second pass that tries to place what the greedy first pass left out. +//! +//! The greedy pass places each task at the earliest time it fits and never +//! moves a placement once it is made, so a task that does not fit is lost +//! even when moving a placement would make room. This pass addresses the +//! cheapest case of that problem. +//! +//! For a task that was left out, the pass looks at the placements that block +//! it, that is the placements sharing a resource it needs. For each blocker +//! it tries a relocation of the pair: +//! +//! 1. Record where the blocker sits, then remove it. +//! 2. Find the earliest time at which the waiting task fits without it. +//! 3. Place the waiting task there, then ask whether the blocker fits +//! somewhere else now that it has a new neighbour. +//! 4. Keep the new arrangement when both fit, and undo it otherwise. +//! +//! The check in step 4 is the whole safety argument: the pair is kept only +//! when both placements pass the same feasibility test the greedy pass uses, +//! so every accepted relocation leaves the schedule feasible. +//! +//! The pass is bounded. Each relocated pair costs two feasibility searches, +//! and the pair is attempted only a bounded number of times, so the pass +//! stays linear in the number of tasks. It is still a heuristic: scheduling +//! with resource constraints is intractable, so the pass reduces the loss +//! rather than removing it, and the caller continues to see what was left +//! out. + +use crate::greedy::earliest_feasible_start; +use apsis_resource::ResourceSet; +use apsis_schedule::Schedule; +use apsis_task::{Task, TaskId, TaskState}; + +/// The settings of the improvement pass. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ImprovementSettings { + /// Whether the pass runs. + pub enabled: bool, + /// The greatest number of blocking placements to try per task. + /// + /// Each attempt costs two feasibility searches. A small bound keeps the + /// pass predictable; a larger one finds more placements on a crowded + /// schedule at proportional cost. + pub max_attempts_per_task: usize, +} + +impl Default for ImprovementSettings { + /// Returns settings that run the pass with four attempts per task. + fn default() -> Self { + Self { + enabled: true, + max_attempts_per_task: 4, + } + } +} + +impl ImprovementSettings { + /// Creates settings with the given bound. + pub const fn new(max_attempts_per_task: usize) -> Self { + Self { + enabled: true, + max_attempts_per_task, + } + } + + /// Returns settings that do not run the pass. + pub const fn disabled() -> Self { + Self { + enabled: false, + max_attempts_per_task: 0, + } + } +} + +/// What the improvement pass changed. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ImprovementOutcome { + /// The identifiers of the tasks the pass placed. + pub placed: Vec, + /// The identifiers of the tasks the pass moved to make room. + pub moved: Vec, +} + +impl ImprovementOutcome { + /// Returns the number of tasks the pass placed. + pub fn placed_count(&self) -> usize { + self.placed.len() + } + + /// Returns `true` when the pass changed nothing. + pub fn is_empty(&self) -> bool { + self.placed.is_empty() && self.moved.is_empty() + } +} + +/// Tries to place deferred tasks by relocating the placements that block them. +/// +/// `deferred` are the tasks the greedy pass could not place, in the order to +/// consider them. `states` holds the state of every task indexed by +/// identifier and is updated in place, so the caller sees the final +/// assignment. The schedule is modified in place. +/// +/// Returns a record of what the pass placed and moved. +pub fn improve( + tasks: &[Task], + resources: &ResourceSet, + deferred: &[TaskId], + schedule: &mut Schedule, + states: &mut [TaskState], + settings: ImprovementSettings, +) -> ImprovementOutcome { + let mut outcome = ImprovementOutcome::default(); + if !settings.enabled || settings.max_attempts_per_task == 0 { + return outcome; + } + + for id in deferred { + let Some(waiting) = tasks.iter().find(|task| task.id == *id) else { + continue; + }; + // A task that is not deferred has been placed by an earlier step. + if schedule.is_placed(*id) { + continue; + } + + for blocker_id in blocking_tasks(waiting, tasks, schedule) + .into_iter() + .take(settings.max_attempts_per_task) + { + let Some(blocker) = tasks.iter().find(|task| task.id == blocker_id) else { + continue; + }; + if relocate_pair(waiting, blocker, tasks, schedule, resources) { + let start = schedule + .placement_of(*id) + .map_or(0.0, |placement| placement.start); + set_state(states, *id, TaskState::Scheduled { start }); + let blocker_start = schedule + .placement_of(blocker_id) + .map_or(0.0, |placement| placement.start); + set_state( + states, + blocker_id, + TaskState::Scheduled { + start: blocker_start, + }, + ); + outcome.placed.push(*id); + outcome.moved.push(blocker_id); + break; + } + } + } + + outcome.placed.sort(); + outcome.placed.dedup(); + outcome.moved.sort(); + outcome.moved.dedup(); + outcome +} + +/// Returns the placed tasks that share a resource with a waiting task, in +/// time order. +/// +/// Only these can block it: a placement on a resource the task does not need +/// consumes capacity that the task was never going to use. +fn blocking_tasks(waiting: &Task, tasks: &[Task], schedule: &Schedule) -> Vec { + let mut blockers: Vec<(f64, TaskId)> = schedule + .placements() + .iter() + .filter(|placement| { + tasks + .iter() + .find(|other| other.id == placement.task) + .is_some_and(|other| { + waiting + .requirements + .iter() + .any(|requirement| other.demand_for(requirement.resource).is_some()) + }) + }) + .map(|placement| (placement.start, placement.task)) + .collect(); + blockers.sort_by(|first, second| { + first + .0 + .partial_cmp(&second.0) + .unwrap_or(core::cmp::Ordering::Equal) + .then_with(|| first.1.cmp(&second.1)) + }); + blockers.into_iter().map(|(_, task)| task).collect() +} + +/// Tries to place a waiting task by moving a blocker, keeping both only when +/// both fit. +/// +/// Returns `true` when the pair was rearranged and both tasks are placed. On +/// a `false` return the schedule is exactly as it was. +fn relocate_pair( + waiting: &Task, + blocker: &Task, + tasks: &[Task], + schedule: &mut Schedule, + resources: &ResourceSet, +) -> bool { + let Some(blocker_start) = schedule.placement_of(blocker.id).map(|p| p.start) else { + return false; + }; + // Remove the blocker and look for a home for the waiting task. + if schedule.unplace(blocker.id).is_err() { + return false; + } + let waiting_start = earliest_feasible_start(waiting, tasks, schedule, resources); + if waiting_start.is_none() { + // Nothing changed, so restore the blocker. + let _ = schedule.place(blocker, blocker_start); + return false; + } + let waiting_start = waiting_start.expect("checked above"); + if schedule.place(waiting, waiting_start).is_err() { + let _ = schedule.place(blocker, blocker_start); + return false; + } + + // With the waiting task placed, look for a new home for the blocker. + match earliest_feasible_start(blocker, tasks, schedule, resources) { + Some(blocker_new_start) => { + if schedule.place(blocker, blocker_new_start).is_ok() { + return true; + } + // The search said the placement was feasible, so a failure here + // can only come from the blocker being placed already, which it + // is not. Restore the original arrangement and report failure + // rather than leaving a half applied move. + let _ = schedule.unplace(waiting.id); + let _ = schedule.place(blocker, blocker_start); + false + } + None => { + // The blocker has nowhere to go, so the move is abandoned and + // the original arrangement restored. + let _ = schedule.unplace(waiting.id); + let _ = schedule.place(blocker, blocker_start); + false + } + } +} + +/// Updates the recorded state of a task, ignoring an identifier outside the +/// state list. +fn set_state(states: &mut [TaskState], id: TaskId, state: TaskState) { + if let Some(slot) = states.get_mut(id.index()) { + *slot = state; + } +} diff --git a/crates/ops/apsis-scheduler/src/lib.rs b/crates/ops/apsis-scheduler/src/lib.rs index a239eab..34b1bb0 100644 --- a/crates/ops/apsis-scheduler/src/lib.rs +++ b/crates/ops/apsis-scheduler/src/lib.rs @@ -44,14 +44,16 @@ #![deny(missing_docs)] mod greedy; +mod improve; mod planner; pub use greedy::{ demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at, resources_have_capacity, }; +pub use improve::{ImprovementOutcome, ImprovementSettings, improve}; pub use planner::{PlanOutcome, Planner, SchedulingResult}; pub use apsis_resource::{ResourceId, ResourceSet}; pub use apsis_schedule::Schedule; -pub use apsis_task::{Task, TaskId}; +pub use apsis_task::{PrecedenceGraph, Task, TaskId}; diff --git a/crates/ops/apsis-scheduler/src/planner.rs b/crates/ops/apsis-scheduler/src/planner.rs index b9f356d..54cdf65 100644 --- a/crates/ops/apsis-scheduler/src/planner.rs +++ b/crates/ops/apsis-scheduler/src/planner.rs @@ -1,9 +1,10 @@ //! The greedy planner. use crate::greedy::{earliest_feasible_start, infeasible_tasks}; +use crate::improve::{ImprovementSettings, improve}; use apsis_resource::ResourceSet; use apsis_schedule::Schedule; -use apsis_task::{Task, TaskId, TaskState, task_order}; +use apsis_task::{PrecedenceGraph, Task, TaskId, TaskState, task_order}; /// What happened to one task during planning. /// @@ -38,7 +39,17 @@ pub struct SchedulingResult { /// The schedule that was produced. pub schedule: Schedule, /// The outcome of every task, in the order the planner considered them. + /// + /// The order is the order of the task list the planner was given, not the + /// order in which the planner placed them, so that a caller can index the + /// outcomes by the position of the task in its own list. pub outcomes: Vec, + /// The identifiers of the tasks that the improvement pass placed. + /// + /// These tasks were left out by the greedy pass and placed afterwards by + /// moving other placements. They are also recorded as scheduled in the + /// outcomes. + pub improved: Vec, } impl SchedulingResult { @@ -84,12 +95,31 @@ impl SchedulingResult { /// A plan that leaves a mandatory task unplaced is not usable, however /// much optional work it found room for. pub fn honours_mandatory(&self, tasks: &[Task]) -> bool { - let mandatory: Vec = tasks + tasks .iter() .filter(|task| task.mandatory) - .map(|task| task.id) - .collect(); - mandatory.iter().all(|id| self.schedule.is_placed(*id)) + .all(|task| self.schedule.is_placed(task.id)) + } + + /// Returns `true` when every precedence constraint is satisfied. + /// + /// A constraint is satisfied when the second task starts at or after the + /// end of the first. A constraint whose tasks are not both placed is + /// trivially satisfied, because nothing has been promised about them. + pub fn honours_precedence(&self, graph: &PrecedenceGraph) -> bool { + self.outcomes + .iter() + .filter(|outcome| outcome.is_scheduled()) + .all(|outcome| { + let Some(after) = self.schedule.placement_of(outcome.task) else { + return true; + }; + graph.predecessors_of(outcome.task).iter().all(|before| { + self.schedule + .placement_of(*before) + .is_none_or(|placement| placement.end() <= after.start) + }) + }) } } @@ -108,22 +138,30 @@ pub struct Planner { /// left, while the reverse order could consume the only window the /// mandatory task had. pub mandatory_first: bool, + /// The settings of the improvement pass. + pub improvement: ImprovementSettings, } impl Default for Planner { - /// Returns a planner that places mandatory tasks first. + /// Returns a planner that places mandatory tasks first and runs the + /// improvement pass. fn default() -> Self { Self { mandatory_first: true, + improvement: ImprovementSettings::default(), } } } impl Planner { /// Creates a planner with the default settings. + /// + /// The defaults are the ones [`Planner::default`] uses: mandatory tasks + /// first and the improvement pass enabled with four attempts per task. pub const fn new() -> Self { Self { mandatory_first: true, + improvement: ImprovementSettings::new(4), } } @@ -133,26 +171,61 @@ impl Planner { self } + /// Returns a copy of this planner with the improvement pass configured. + pub const fn with_improvement(mut self, improvement: ImprovementSettings) -> Self { + self.improvement = improvement; + self + } + /// Returns the order in which the planner considers the tasks. /// - /// Mandatory tasks come first when the planner is configured that way, - /// each group ordered by [`task_order`], which is decreasing weighted + /// Without a precedence graph the order is mandatory tasks first, when + /// the planner is configured that way, and then decreasing weighted /// value with ties broken by deadline and identifier. + /// + /// With a precedence graph the order is a topological order of the + /// graph, using the value order to break ties. That places a task as + /// early as its constraints allow and the most valuable one first among + /// the tasks that are free. pub fn order(&self, tasks: &[Task]) -> Vec { + self.order_with_precedence(tasks, None) + } + + /// Returns the order in which the planner considers the tasks, honouring + /// a precedence graph. + /// + /// A graph whose constraints contain a cycle produces the value order + /// unchanged, because the constraints then have no satisfiable + /// interpretation. The caller should validate the graph before + /// planning; the order is a fallback so that planning still produces + /// something rather than failing outright. + pub fn order_with_precedence( + &self, + tasks: &[Task], + precedence: Option<&PrecedenceGraph>, + ) -> Vec { let ordered = task_order(tasks); - if !self.mandatory_first { - return ordered; + let valued = if !self.mandatory_first { + ordered + } else { + let mandatory: Vec = tasks + .iter() + .filter(|task| task.mandatory) + .map(|task| task.id) + .collect(); + let mut result = Vec::with_capacity(ordered.len()); + result.extend(ordered.iter().filter(|id| mandatory.contains(id)).copied()); + result.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied()); + result + }; + + let Some(graph) = precedence else { + return valued; + }; + if graph.is_empty() { + return valued; } - let mandatory: Vec = tasks - .iter() - .filter(|task| task.mandatory) - .map(|task| task.id) - .collect(); - // The stable partition keeps the relative order within each group. - let mut result = Vec::with_capacity(ordered.len()); - result.extend(ordered.iter().filter(|id| mandatory.contains(id)).copied()); - result.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied()); - result + graph.topological_order(&valued).unwrap_or(valued) } /// Plans a set of tasks on a set of resources. @@ -166,13 +239,50 @@ impl Planner { /// each placement is checked against the capacity before it is made. The /// caller may confirm that with `apsis-conflict`. pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult { - let mut schedule = Schedule::new(); - let mut outcomes = Vec::with_capacity(tasks.len()); + self.plan(tasks, resources, None) + } - // Tasks that cannot be placed at all are reported without a search. + /// Plans a set of tasks, honouring a precedence graph. + /// + /// A task is placed only after every task that must precede it has been + /// placed, so the search for its start begins no earlier than the latest + /// end among its predecessors. A predecessor that could not be placed + /// makes the successor deferred as well, because the constraint cannot be + /// met. + /// + /// The improvement pass is not applied to a plan with precedence + /// constraints, because relocating a placement could break a constraint + /// that the pass does not consider. A caller that needs both should + /// improve the plan and then verify it with + /// [`SchedulingResult::honours_precedence`]. + pub fn schedule_with_precedence( + &self, + tasks: &[Task], + resources: &ResourceSet, + precedence: &PrecedenceGraph, + ) -> SchedulingResult { + self.plan(tasks, resources, Some(precedence)) + } + + /// The shared planning routine. + fn plan( + &self, + tasks: &[Task], + resources: &ResourceSet, + precedence: Option<&PrecedenceGraph>, + ) -> SchedulingResult { + let mut schedule = Schedule::new(); + // The state of every task, indexed by identifier, so that a + // predecessor's placement can be looked up by index. + let mut states = vec![TaskState::Pending; tasks.len()]; + let mut outcomes = Vec::with_capacity(tasks.len()); + let mut order: Vec = self.order_with_precedence(tasks, precedence); + // A deferred task is one whose search failed, as opposed to one whose + // structural preconditions failed. + let mut deferred: Vec = Vec::new(); let infeasible = infeasible_tasks(tasks, resources); - for id in self.order(tasks) { + for id in order.drain(..) { let task = tasks .iter() .find(|task| task.id == id) @@ -184,6 +294,13 @@ impl Planner { } else { format!("the resources cannot hold the demand of {id}") }; + set_state( + &mut states, + id, + TaskState::Infeasible { + reason: reason.clone(), + }, + ); outcomes.push(PlanOutcome { task: id, state: TaskState::Infeasible { reason }, @@ -191,32 +308,144 @@ impl Planner { continue; } - match earliest_feasible_start(task, tasks, &schedule, resources) { - Some(start) => { - // The placement was checked to be feasible, so the call - // succeeds. The schedule may still refuse it if the task - // is already placed, which it is not, because each task - // is considered once. - match schedule.place(task, start) { - Ok(_) => outcomes.push(PlanOutcome { + // A task may not start before the latest end among the tasks that + // must precede it. + let earliest = match self.precedence_floor(id, tasks, precedence, &schedule, &states) { + Ok(floor) => floor, + Err(reason) => { + set_state(&mut states, id, TaskState::Deferred); + outcomes.push(PlanOutcome { + task: id, + state: TaskState::Deferred, + }); + let _ = reason; + deferred.push(id); + continue; + } + }; + + // The task with the floor applied, so that the search respects + // the constraints. + let constrained = match earliest { + Some(floor) if floor > task.earliest_start => { + let mut copy = task.clone(); + copy.earliest_start = floor; + copy + } + _ => task.clone(), + }; + + match earliest_feasible_start(&constrained, tasks, &schedule, resources) { + Some(start) => match schedule.place(task, start) { + Ok(_) => { + set_state(&mut states, id, TaskState::Scheduled { start }); + outcomes.push(PlanOutcome { task: id, state: TaskState::Scheduled { start }, - }), - Err(error) => outcomes.push(PlanOutcome { - task: id, - state: TaskState::Infeasible { - reason: error.to_string(), - }, - }), + }); } + Err(error) => { + let reason = error.to_string(); + set_state( + &mut states, + id, + TaskState::Infeasible { + reason: reason.clone(), + }, + ); + outcomes.push(PlanOutcome { + task: id, + state: TaskState::Infeasible { reason }, + }); + } + }, + None => { + set_state(&mut states, id, TaskState::Deferred); + outcomes.push(PlanOutcome { + task: id, + state: TaskState::Deferred, + }); + deferred.push(id); } - None => outcomes.push(PlanOutcome { - task: id, - state: TaskState::Deferred, - }), } } - SchedulingResult { schedule, outcomes } + // The improvement pass runs only for a plan without precedence + // constraints, for the reason given on `schedule_with_precedence`. + let improved = if precedence.is_some() { + Vec::new() + } else { + let outcome = improve( + tasks, + resources, + &deferred, + &mut schedule, + &mut states, + self.improvement, + ); + // The outcomes that the pass changed must be updated, and the + // order of the outcomes follows the task list so that a caller + // can index them by position. + for id in &outcome.placed { + if let Some(slot) = outcomes.iter_mut().find(|entry| entry.task == *id) { + let start = schedule + .placement_of(*id) + .map_or(0.0, |placement| placement.start); + slot.state = TaskState::Scheduled { start }; + } + } + outcome.placed + }; + + // Reorder the outcomes to follow the task list, which is the order a + // caller expects when it indexes by its own position. + outcomes.sort_by_key(|outcome| outcome.task.index()); + + SchedulingResult { + schedule, + outcomes, + improved, + } + } + + /// Returns the earliest start a task may have, given its predecessors. + /// + /// Returns `Ok(None)` when there is no constraint, `Ok(Some(t))` when the + /// task may start no earlier than `t`, and `Err` when a predecessor was + /// not placed, which makes the constraint unsatisfiable. + fn precedence_floor( + &self, + id: TaskId, + _tasks: &[Task], + precedence: Option<&PrecedenceGraph>, + schedule: &Schedule, + states: &[TaskState], + ) -> Result, String> { + let Some(graph) = precedence else { + return Ok(None); + }; + let mut floor: Option = None; + for predecessor in graph.predecessors_of(id) { + // A predecessor that was deferred or is infeasible cannot be + // waited for. + let placed = schedule.placement_of(predecessor); + let Some(placement) = placed else { + let reason = match states.get(predecessor.index()) { + Some(TaskState::Infeasible { reason }) => reason.clone(), + _ => format!("{predecessor} was not placed"), + }; + return Err(reason); + }; + floor = + Some(floor.map_or(placement.end(), |current: f64| current.max(placement.end()))); + } + Ok(floor) + } +} + +/// Records the state of a task, ignoring an identifier outside the list. +fn set_state(states: &mut [TaskState], id: TaskId, state: TaskState) { + if let Some(slot) = states.get_mut(id.index()) { + *slot = state; } } diff --git a/crates/ops/apsis-scheduler/tests/planner.rs b/crates/ops/apsis-scheduler/tests/planner.rs index d660fcc..b61b1c6 100644 --- a/crates/ops/apsis-scheduler/tests/planner.rs +++ b/crates/ops/apsis-scheduler/tests/planner.rs @@ -148,10 +148,12 @@ fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() { .expect("the mandatory task is placed"); assert_eq!(mandatory_placement.start, 0.0); - // With the rule turned off, the optional task goes first and the + // With the rule turned off, and the improvement pass disabled so that + // only the ordering is under test, the optional task goes first and the // mandatory one is placed after it, using the rest of its window. let without = Planner::new() .with_mandatory_first(false) + .with_improvement(apsis_scheduler::ImprovementSettings::disabled()) .schedule(&tasks, &resources); let optional_placement = without .schedule @@ -160,6 +162,40 @@ fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() { assert_eq!(optional_placement.start, 0.0); } +#[test] +fn the_improvement_pass_recovers_a_plan_that_the_greedy_order_missed() { + // The optional task is worth more, so it is placed first and occupies + // the resource from zero to sixty. The mandatory task has a window from + // zero to a hundred, so it cannot fit after the optional one and would + // be lost. The improvement pass moves the optional task aside and places + // both. + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let optional = task(0, 60.0, &[0]) + .with_priority(100.0) + .with_window(0.0, 1_000.0); + let mandatory = task(1, 60.0, &[0]) + .with_priority(1.0) + .with_window(0.0, 100.0) + .with_mandatory(true); + let tasks = [optional, mandatory]; + + let greedy = Planner::new() + .with_mandatory_first(false) + .with_improvement(apsis_scheduler::ImprovementSettings::disabled()) + .schedule(&tasks, &resources); + // Without the pass the mandatory task is lost. + assert!(!greedy.honours_mandatory(&tasks)); + assert_eq!(greedy.schedule.len(), 1); + + let improved = Planner::new() + .with_mandatory_first(false) + .schedule(&tasks, &resources); + // With the pass both are placed. + assert!(improved.is_complete(), "{:?}", improved.outcomes); + assert!(improved.honours_mandatory(&tasks)); + assert!(!improved.improved.is_empty()); +} + #[test] fn the_value_ordering_places_the_most_valuable_task_first() { let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); diff --git a/crates/ops/apsis-scheduler/tests/precedence.rs b/crates/ops/apsis-scheduler/tests/precedence.rs new file mode 100644 index 0000000..77adfcd --- /dev/null +++ b/crates/ops/apsis-scheduler/tests/precedence.rs @@ -0,0 +1,295 @@ +//! Tests for precedence honoured by the planner, and for the improvement +//! pass. + +use apsis_resource::{Resource, ResourceId, ResourceSet, Window}; +use apsis_scheduler::{ImprovementSettings, Planner}; +use apsis_task::{PrecedenceGraph, 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 +} + +#[test] +fn a_chain_of_tasks_is_placed_in_order() { + // Three tasks that must run one after another on the same resource. + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [ + task(0, 60.0, &[0]), + task(1, 30.0, &[0]), + task(2, 90.0, &[0]), + ]; + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(0), TaskId::new(1)).expect("no cycle"); + graph.add(TaskId::new(1), TaskId::new(2)).expect("no cycle"); + + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + assert!(result.is_complete(), "{:?}", result.outcomes); + assert!(result.honours_precedence(&graph)); + + let first = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + let second = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + let third = result + .schedule + .placement_of(TaskId::new(2)) + .expect("placed"); + assert_eq!(first.start, 0.0); + assert_eq!(second.start, 60.0); + assert_eq!(third.start, 90.0); +} + +#[test] +fn a_precedence_floor_delays_a_task_that_could_start_earlier() { + // The second task could start at zero on its own, but must wait for the + // first, which runs on a different resource. + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct"); + let tasks = [task(0, 100.0, &[0]), task(1, 60.0, &[1])]; + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(0), TaskId::new(1)).expect("no cycle"); + + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + assert!(result.is_complete()); + let second = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + // The second resource was free from the start, but the constraint delays + // the task to the end of the first. + assert_eq!(second.start, 100.0); + assert!(result.honours_precedence(&graph)); +} + +#[test] +fn a_diamond_precedence_is_honoured() { + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct"); + // The first task feeds two parallel tasks, which both feed a last one. + let tasks = [ + task(0, 100.0, &[0]), + task(1, 60.0, &[1]), + task(2, 60.0, &[1]), + task(3, 30.0, &[0]), + ]; + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(0), TaskId::new(1)).expect("no cycle"); + graph.add(TaskId::new(0), TaskId::new(2)).expect("no cycle"); + graph.add(TaskId::new(1), TaskId::new(3)).expect("no cycle"); + graph.add(TaskId::new(2), TaskId::new(3)).expect("no cycle"); + + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + assert!(result.is_complete(), "{:?}", result.outcomes); + assert!(result.honours_precedence(&graph)); + + // The last task must follow both of the parallel ones. + let last = result + .schedule + .placement_of(TaskId::new(3)) + .expect("placed"); + for parallel in [1_usize, 2] { + let placement = result + .schedule + .placement_of(TaskId::new(parallel)) + .expect("placed"); + assert!( + placement.end() <= last.start, + "{placement:?} against {last:?}" + ); + } +} + +#[test] +fn a_sequential_observation_and_downlink_is_expressed_as_two_tasks() { + // An observation uses the payload and a downlink uses the transmitter, + // and the downlink must follow the observation. This is the pattern for + // a task that needs two resources in sequence. + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct"); + let observation = task(0, 120.0, &[0]).with_window(50.0, 500.0); + let downlink = task(1, 90.0, &[1]).with_window(0.0, 1_000.0); + let tasks = [observation, downlink]; + + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(0), TaskId::new(1)).expect("no cycle"); + + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + assert!(result.is_complete()); + assert!(result.honours_precedence(&graph)); + + let collection = result + .schedule + .placement_of(TaskId::new(0)) + .expect("placed"); + let transmission = result + .schedule + .placement_of(TaskId::new(1)) + .expect("placed"); + assert_eq!(collection.start, 50.0); + // The downlink starts when the collection ends. + assert_eq!(transmission.start, 170.0); + // The two use different resources, so they may be placed back to back + // without a conflict. + assert_eq!(collection.end(), transmission.start); +} + +#[test] +fn a_successor_of_an_unplaced_task_is_deferred() { + // The first task cannot be placed, so the constraint cannot be met and + // the second is deferred rather than placed early. + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let impossible = task(0, 90.0, &[0]).with_window(0.0, 60.0); + let successor = task(1, 30.0, &[0]); + let tasks = [impossible, successor]; + + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(0), TaskId::new(1)).expect("no cycle"); + + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + assert!(!result.schedule.is_placed(TaskId::new(1))); + assert!(result.deferred().contains(&TaskId::new(1))); + // The constraints are trivially satisfied, because nothing was promised. + assert!(result.honours_precedence(&graph)); +} + +#[test] +fn the_precedence_aware_order_is_topological() { + let tasks = [ + Task::new(TaskId::new(0), 60.0), + Task::new(TaskId::new(1), 60.0), + Task::new(TaskId::new(2), 60.0), + ]; + let mut graph = PrecedenceGraph::new(); + graph.add(TaskId::new(2), TaskId::new(0)).expect("no cycle"); + + let order = Planner::new().order_with_precedence(&tasks, Some(&graph)); + let position = |id: TaskId| { + order + .iter() + .position(|candidate| *candidate == id) + .expect("present") + }; + assert!(position(TaskId::new(2)) < position(TaskId::new(0))); +} + +#[test] +fn a_cycle_in_the_graph_falls_back_to_the_value_order() { + // The graph refuses a cycle, so a cycle can only exist if the caller + // builds the graph from one task set and orders another. The planner + // then falls back to the value order rather than failing. + let tasks = [ + Task::new(TaskId::new(0), 60.0), + Task::new(TaskId::new(1), 60.0), + ]; + let graph = PrecedenceGraph::new(); + let order = Planner::new().order_with_precedence(&tasks, Some(&graph)); + // An empty graph gives the value order, which for equal values is by + // identifier. + assert_eq!(order, vec![TaskId::new(0), TaskId::new(1)]); +} + +#[test] +fn the_improvement_pass_is_skipped_when_precedence_is_used() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])]; + let graph = PrecedenceGraph::new(); + let result = Planner::new().schedule_with_precedence(&tasks, &resources, &graph); + // The pass does not run, so nothing is reported as improved. + assert!(result.improved.is_empty()); +} + +#[test] +fn the_improvement_pass_can_be_disabled() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let optional = task(0, 60.0, &[0]) + .with_priority(100.0) + .with_window(0.0, 1_000.0); + let mandatory = task(1, 60.0, &[0]) + .with_priority(1.0) + .with_window(0.0, 100.0) + .with_mandatory(true); + let tasks = [optional, mandatory]; + + let planner = Planner::new() + .with_mandatory_first(false) + .with_improvement(ImprovementSettings::disabled()); + let result = planner.schedule(&tasks, &resources); + assert!(result.improved.is_empty()); + assert_eq!(result.schedule.len(), 1); +} + +#[test] +fn the_improvement_pass_preserves_feasibility() { + // Whatever the pass decides, the result must remain conflict free. + let resources = + ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 2.0)]).expect("distinct"); + let tasks: Vec = (0..10) + .map(|index| { + task(index, 40.0 + f64::from(index as u32) * 5.0, &[index % 2]) + .with_priority(1.0 + f64::from(index as u32)) + }) + .collect(); + + let result = Planner::new().schedule(&tasks, &resources); + // The schedule must not place two tasks beyond the capacity of a + // resource at the same time. + let report = apsis_conflict::detect(&result.schedule, &tasks, &resources); + assert!( + report.is_clear(), + "the improvement pass broke feasibility: {:?}", + report.conflicts() + ); +} + +#[test] +fn the_improvement_pass_never_loses_a_placement() { + // Every task that the greedy pass placed must still be placed + // afterwards, because the pass only adds placements or moves them. + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks: Vec = (0..6) + .map(|index| task(index, 60.0, &[0]).with_priority(1.0 + f64::from(index as u32))) + .collect(); + + let with_pass = Planner::new().schedule(&tasks, &resources); + let without_pass = Planner::new() + .with_improvement(ImprovementSettings::disabled()) + .schedule(&tasks, &resources); + assert!( + with_pass.schedule.len() >= without_pass.schedule.len(), + "{} against {}", + with_pass.schedule.len(), + without_pass.schedule.len() + ); +} + +#[test] +fn the_improvement_settings_are_validated_by_use() { + let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one"); + let tasks = [task(0, 60.0, &[0])]; + // A bound of zero disables the move search but leaves the pass enabled, + // which behaves as if the pass were off. + let planner = Planner::new().with_improvement(ImprovementSettings::new(0)); + let result = planner.schedule(&tasks, &resources); + assert!(result.is_complete()); + assert!(result.improved.is_empty()); +} diff --git a/crates/ops/apsis-task/src/error.rs b/crates/ops/apsis-task/src/error.rs index ce7014b..d8be130 100644 --- a/crates/ops/apsis-task/src/error.rs +++ b/crates/ops/apsis-task/src/error.rs @@ -22,6 +22,8 @@ pub enum TaskErrorKind { NoSuchTask, /// A task with the identifier already exists. DuplicateTask, + /// A precedence constraint would close a cycle. + PrecedenceCycle, } impl fmt::Display for TaskErrorKind { @@ -35,6 +37,7 @@ impl fmt::Display for TaskErrorKind { Self::InvalidWeight => "a priority or value is not finite", Self::NoSuchTask => "no such task", Self::DuplicateTask => "a task with that identifier exists", + Self::PrecedenceCycle => "the precedence constraints contain a cycle", }; f.write_str(text) } diff --git a/crates/ops/apsis-task/src/lib.rs b/crates/ops/apsis-task/src/lib.rs index a2518c1..8ad8508 100644 --- a/crates/ops/apsis-task/src/lib.rs +++ b/crates/ops/apsis-task/src/lib.rs @@ -8,8 +8,9 @@ //! A task requires all of its resources at the same time, for its whole //! duration. That is the model an observation has: the satellite and the //! ground station must both be available while the data is taken. A task that -//! needs resources in sequence is expressed as two tasks with a -//! precedence constraint, which the scheduling layer provides. +//! needs resources in sequence is expressed as two tasks joined by a +//! precedence constraint in a [`PrecedenceGraph`], which says that one task +//! must finish before the other starts. //! //! Tasks are immutable once built. The state a scheduler assigns to one is //! kept separately, in the schedule, so that the same task can be considered @@ -37,9 +38,11 @@ #![deny(missing_docs)] mod error; +mod precedence; mod task; pub use error::{TaskError, TaskErrorKind}; +pub use precedence::PrecedenceGraph; pub use task::{Requirement, Task, TaskId, TaskSet, TaskState, task_order}; pub use apsis_resource::ResourceId; diff --git a/crates/ops/apsis-task/src/precedence.rs b/crates/ops/apsis-task/src/precedence.rs new file mode 100644 index 0000000..4cf594f --- /dev/null +++ b/crates/ops/apsis-task/src/precedence.rs @@ -0,0 +1,245 @@ +//! Precedence constraints between tasks. +//! +//! A precedence constraint says that one task must finish before another +//! starts. A set of such constraints forms a directed acyclic graph, and a +//! scheduler must respect it: a task cannot be placed until every task that +//! must precede it has been placed and has finished. +//! +//! The graph is stored as adjacency lists. It rejects a cycle when an edge is +//! added, because a cycle is not a set of constraints that can be satisfied +//! and would otherwise produce an empty topological order that a caller +//! might mistake for a scheduling failure rather than a modelling error. +//! +//! The precedence graph is a separate type from [`Task`](crate::Task) +//! because the constraints are a property of the set of tasks rather than of +//! any one task, and because a caller may want to plan the same tasks under +//! different constraint graphs. + +use crate::error::{TaskError, TaskErrorKind}; +use crate::task::TaskId; +use std::collections::BTreeMap; + +/// A directed acyclic graph of precedence constraints. +/// +/// An edge from one task to another means the first must finish before the +/// second starts. The order of the tasks themselves is the caller's: the +/// graph only records which pairs are related. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PrecedenceGraph { + /// The successors of each task, in the order the edges were added. + successors: BTreeMap>, + /// The predecessors of each task, in the order the edges were added. + predecessors: BTreeMap>, +} + +impl PrecedenceGraph { + /// Creates an empty graph. + pub fn new() -> Self { + Self { + successors: BTreeMap::new(), + predecessors: BTreeMap::new(), + } + } + + /// Returns the number of edges. + pub fn edge_count(&self) -> usize { + self.successors.values().map(Vec::len).sum() + } + + /// Returns `true` when there are no edges. + pub fn is_empty(&self) -> bool { + self.successors.values().all(Vec::is_empty) + } + + /// Returns `true` when the constraint from `before` to `after` exists. + pub fn has_constraint(&self, before: TaskId, after: TaskId) -> bool { + self.successors + .get(&before.index()) + .is_some_and(|successors| successors.contains(&after.index())) + } + + /// Adds a constraint that `before` must finish before `after` starts. + /// + /// Returns an error when the two identifiers are the same, because a task + /// cannot precede itself, or when the constraint would close a cycle. + /// + /// Adding a constraint that already exists is accepted and has no effect, + /// so a caller may assemble a graph from overlapping sources without + /// having to deduplicate. + pub fn add(&mut self, before: TaskId, after: TaskId) -> Result<(), TaskError> { + if before == after { + return Err(TaskError::new(TaskErrorKind::PrecedenceCycle) + .with_detail(format!("{before} cannot precede itself"))); + } + if self.has_constraint(before, after) { + return Ok(()); + } + // A new edge from `before` to `after` closes a cycle exactly when + // `before` is already reachable from `after`. That is checked before + // the edge is inserted, so a rejected edge leaves the graph + // unchanged. + if self.is_reachable(after, before) { + return Err( + TaskError::new(TaskErrorKind::PrecedenceCycle).with_detail(format!( + "{before} must precede {after}, but {after} already precedes {before}" + )), + ); + } + self.successors + .entry(before.index()) + .or_default() + .push(after.index()); + self.predecessors + .entry(after.index()) + .or_default() + .push(before.index()); + Ok(()) + } + + /// Returns `true` when `to` is reachable from `from` following the + /// constraints. + pub fn is_reachable(&self, from: TaskId, to: TaskId) -> bool { + if from == to { + return true; + } + let mut visited: Vec = Vec::new(); + let mut stack = vec![from.index()]; + while let Some(node) = stack.pop() { + if node == to.index() { + return true; + } + if visited.contains(&node) { + continue; + } + visited.push(node); + if let Some(successors) = self.successors.get(&node) { + stack.extend(successors.iter().copied()); + } + } + false + } + + /// Returns the tasks that must finish before a task starts. + /// + /// The result is in the order the constraints were added. It is the + /// direct predecessors only, not the transitive closure. + pub fn predecessors_of(&self, task: TaskId) -> Vec { + self.predecessors + .get(&task.index()) + .map(|list| list.iter().map(|index| TaskId::new(*index)).collect()) + .unwrap_or_default() + } + + /// Returns the tasks that must wait for a task to finish. + /// + /// The result is in the order the constraints were added. It is the + /// direct successors only, not the transitive closure. + pub fn successors_of(&self, task: TaskId) -> Vec { + self.successors + .get(&task.index()) + .map(|list| list.iter().map(|index| TaskId::new(*index)).collect()) + .unwrap_or_default() + } + + /// Returns the tasks with no predecessor. + pub fn roots(&self) -> Vec { + // Every task that appears as a successor has a predecessor, so the + // roots are the tasks that appear only as a predecessor. + let mut roots: Vec = Vec::new(); + for task in self.successors.keys() { + if !self.predecessors.contains_key(task) && !roots.contains(task) { + roots.push(*task); + } + } + roots.sort_unstable(); + roots.into_iter().map(TaskId::new).collect() + } + + /// Returns the tasks with no successor. + pub fn leaves(&self) -> Vec { + let mut leaves: Vec = Vec::new(); + for task in self.predecessors.keys() { + if !self.successors.contains_key(task) && !leaves.contains(task) { + leaves.push(*task); + } + } + leaves.sort_unstable(); + leaves.into_iter().map(TaskId::new).collect() + } + + /// Returns a topological order of the given tasks. + /// + /// The order respects every constraint: a task appears after all of its + /// predecessors. Ties are broken by the position of the task in the + /// supplied list, so that a caller can impose its own preference among + /// tasks that are otherwise unordered. That is what lets a planner place + /// the more valuable tasks first within the freedom the constraints + /// allow. + /// + /// Tasks in the list that have no constraints still appear. + /// + /// Returns an error when the graph contains a cycle among the given + /// tasks. The graph rejects cycles as they are added, so this can only + /// happen when a caller builds the graph from one task set and orders + /// another, or when the tasks are not all in the graph. + pub fn topological_order(&self, tasks: &[TaskId]) -> Result, TaskError> { + // The position of each task in the supplied list, which breaks ties. + let mut preference: BTreeMap = BTreeMap::new(); + for (position, task) in tasks.iter().enumerate() { + preference.insert(task.index(), position); + } + + // The number of predecessors of each task that are themselves in the + // list. A predecessor outside the list is ignored, because it cannot + // be scheduled and so cannot be waited for. + let mut pending: BTreeMap = BTreeMap::new(); + for task in tasks { + let count = self + .predecessors_of(*task) + .iter() + .filter(|predecessor| preference.contains_key(&predecessor.index())) + .count(); + pending.insert(task.index(), count); + } + + let mut order: Vec = Vec::with_capacity(tasks.len()); + let mut placed: Vec = Vec::with_capacity(tasks.len()); + + while order.len() < tasks.len() { + // Among the tasks whose predecessors are all placed, take the + // one the caller prefers. + let next = tasks + .iter() + .map(|task| task.index()) + .filter(|index| !placed.contains(index)) + .filter(|index| pending.get(index).copied().unwrap_or(0) == 0) + .min_by_key(|index| preference.get(index).copied().unwrap_or(usize::MAX)); + + let Some(next) = next else { + return Err(TaskError::new(TaskErrorKind::PrecedenceCycle) + .with_detail("no task is free of unplaced predecessors")); + }; + + placed.push(next); + order.push(next); + for successor in self.successors_of(TaskId::new(next)) { + if let Some(count) = pending.get_mut(&successor.index()) { + *count = count.saturating_sub(1); + } + } + } + + Ok(order.into_iter().map(TaskId::new).collect()) + } + + /// Returns the number of tasks in the graph. + pub fn task_count(&self) -> usize { + let mut all: Vec = self.successors.keys().copied().collect(); + for task in self.predecessors.keys() { + if !all.contains(task) { + all.push(*task); + } + } + all.len() + } +} diff --git a/crates/ops/apsis-task/tests/precedence.rs b/crates/ops/apsis-task/tests/precedence.rs new file mode 100644 index 0000000..d6822da --- /dev/null +++ b/crates/ops/apsis-task/tests/precedence.rs @@ -0,0 +1,233 @@ +//! Tests for the precedence graph. + +use apsis_task::{PrecedenceGraph, TaskErrorKind, TaskId}; + +/// The identifiers used by the tests. +fn id(index: usize) -> TaskId { + TaskId::new(index) +} + +#[test] +fn an_empty_graph_has_no_edges() { + let graph = PrecedenceGraph::new(); + assert!(graph.is_empty()); + assert_eq!(graph.edge_count(), 0); + assert_eq!(graph.task_count(), 0); + assert!(!graph.has_constraint(id(0), id(1))); + assert!(graph.predecessors_of(id(0)).is_empty()); + assert!(graph.successors_of(id(0)).is_empty()); +} + +#[test] +fn a_constraint_is_recorded_both_ways() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + + assert!(graph.has_constraint(id(0), id(1))); + // The constraint is directed, so the reverse does not exist. + assert!(!graph.has_constraint(id(1), id(0))); + assert_eq!(graph.successors_of(id(0)), vec![id(1)]); + assert_eq!(graph.predecessors_of(id(1)), vec![id(0)]); + assert_eq!(graph.edge_count(), 1); + assert_eq!(graph.task_count(), 2); +} + +#[test] +fn a_self_constraint_is_rejected() { + let mut graph = PrecedenceGraph::new(); + let error = graph + .add(id(0), id(0)) + .expect_err("a task cannot precede itself"); + assert_eq!(error.kind(), TaskErrorKind::PrecedenceCycle); + assert!(graph.is_empty()); +} + +#[test] +fn a_duplicate_constraint_is_accepted_and_ignored() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(0), id(1)).expect("already present"); + // The edge is recorded once. + assert_eq!(graph.edge_count(), 1); + assert_eq!(graph.successors_of(id(0)), vec![id(1)]); +} + +#[test] +fn a_cycle_is_rejected() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(1), id(2)).expect("no cycle"); + // Closing the cycle is refused. + let error = graph.add(id(2), id(0)).expect_err("this closes a cycle"); + assert_eq!(error.kind(), TaskErrorKind::PrecedenceCycle); + // The graph is unchanged. + assert!(!graph.has_constraint(id(2), id(0))); + assert_eq!(graph.edge_count(), 2); +} + +#[test] +fn a_longer_cycle_is_rejected() { + let mut graph = PrecedenceGraph::new(); + for edge in [(0, 1), (1, 2), (2, 3), (3, 4)] { + graph.add(id(edge.0), id(edge.1)).expect("no cycle"); + } + assert_eq!( + graph + .add(id(4), id(1)) + .expect_err("this closes a cycle") + .kind(), + TaskErrorKind::PrecedenceCycle + ); +} + +#[test] +fn reachability_follows_the_direction() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(1), id(2)).expect("no cycle"); + + // The transitive relation holds forward but not backward. + assert!(graph.is_reachable(id(0), id(1))); + assert!(graph.is_reachable(id(0), id(2))); + assert!(graph.is_reachable(id(1), id(2))); + assert!(!graph.is_reachable(id(2), id(0))); + // A task reaches itself by a path of no edges. + assert!(graph.is_reachable(id(0), id(0))); +} + +#[test] +fn roots_and_leaves_are_reported() { + let mut graph = PrecedenceGraph::new(); + // A diamond: 0 before 1 and 2, and both before 3. + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(0), id(2)).expect("no cycle"); + graph.add(id(1), id(3)).expect("no cycle"); + graph.add(id(2), id(3)).expect("no cycle"); + + assert_eq!(graph.roots(), vec![id(0)]); + assert_eq!(graph.leaves(), vec![id(3)]); +} + +#[test] +fn a_chain_has_one_root_and_one_leaf() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(1), id(2)).expect("no cycle"); + assert_eq!(graph.roots(), vec![id(0)]); + assert_eq!(graph.leaves(), vec![id(2)]); +} + +#[test] +fn the_topological_order_respects_the_constraints() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(1), id(2)).expect("no cycle"); + + let tasks = [id(2), id(1), id(0)]; + let order = graph + .topological_order(&tasks) + .expect("the graph is acyclic"); + // The constraints force 0 before 1 before 2, whatever the input order. + assert_eq!(order, vec![id(0), id(1), id(2)]); +} + +#[test] +fn the_topological_order_breaks_ties_by_position() { + let mut graph = PrecedenceGraph::new(); + // Only 0 is constrained, before 1. Tasks 2 and 3 are free. + graph.add(id(0), id(1)).expect("no cycle"); + + // The preference order is 3, 2, 0, 1, so among the free tasks 3 comes + // first. + let tasks = [id(3), id(2), id(0), id(1)]; + let order = graph + .topological_order(&tasks) + .expect("the graph is acyclic"); + assert_eq!(order, vec![id(3), id(2), id(0), id(1)]); +} + +#[test] +fn the_topological_order_honours_the_diamond() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(0), id(2)).expect("no cycle"); + graph.add(id(1), id(3)).expect("no cycle"); + graph.add(id(2), id(3)).expect("no cycle"); + + let tasks = [id(0), id(1), id(2), id(3)]; + let order = graph + .topological_order(&tasks) + .expect("the graph is acyclic"); + assert_eq!(order, vec![id(0), id(1), id(2), id(3)]); + + // The position of each task in the result relative to its constraints. + let position_of = |task: TaskId| { + order + .iter() + .position(|candidate| *candidate == task) + .expect("present") + }; + assert!(position_of(id(0)) < position_of(id(1))); + assert!(position_of(id(0)) < position_of(id(2))); + assert!(position_of(id(1)) < position_of(id(3))); + assert!(position_of(id(2)) < position_of(id(3))); +} + +#[test] +fn the_topological_order_includes_unconstrained_tasks() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + // Task 9 has no constraints at all. + let tasks = [id(0), id(1), id(9)]; + let order = graph + .topological_order(&tasks) + .expect("the graph is acyclic"); + assert_eq!(order.len(), 3); + assert!(order.contains(&id(9))); +} + +#[test] +fn the_topological_order_ignores_predecessors_outside_the_list() { + // A constraint whose predecessor is not in the list cannot be waited + // for, so it is ignored rather than blocking the successor. + let mut graph = PrecedenceGraph::new(); + graph.add(id(5), id(1)).expect("no cycle"); + let tasks = [id(1)]; + assert_eq!( + graph + .topological_order(&tasks) + .expect("the graph is acyclic"), + vec![id(1)] + ); +} + +#[test] +fn the_topological_order_of_an_empty_list_is_empty() { + let graph = PrecedenceGraph::new(); + assert!( + graph + .topological_order(&[]) + .expect("the graph is acyclic") + .is_empty() + ); +} + +#[test] +fn a_graph_with_two_independent_chains_orders_both() { + let mut graph = PrecedenceGraph::new(); + graph.add(id(0), id(1)).expect("no cycle"); + graph.add(id(2), id(3)).expect("no cycle"); + + let tasks = [id(0), id(1), id(2), id(3)]; + let order = graph + .topological_order(&tasks) + .expect("the graph is acyclic"); + let position_of = |task: TaskId| { + order + .iter() + .position(|candidate| *candidate == task) + .expect("present") + }; + assert!(position_of(id(0)) < position_of(id(1))); + assert!(position_of(id(2)) < position_of(id(3))); +}