ops: add precedence constraints and an improvement pass
This commit is contained in:
parent
ea5da41880
commit
436ebcc6f9
|
|
@ -282,6 +282,7 @@ dependencies = [
|
||||||
name = "apsis-scheduler"
|
name = "apsis-scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"apsis-conflict",
|
||||||
"apsis-core",
|
"apsis-core",
|
||||||
"apsis-resource",
|
"apsis-resource",
|
||||||
"apsis-schedule",
|
"apsis-schedule",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
//! 1. Define a satellite and a ground station as resources, taking the
|
//! 1. Define a satellite and a ground station as resources, taking the
|
||||||
//! station's availability from access windows.
|
//! station's availability from access windows.
|
||||||
//! 2. Define observation tasks that each need the satellite and the station.
|
//! 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`.
|
//! 4. Check the result with `apsis-conflict`.
|
||||||
//! 5. Evaluate the schedule with `apsis-schedule`.
|
//! 5. Evaluate the schedule with `apsis-schedule`.
|
||||||
//!
|
//!
|
||||||
|
|
@ -23,8 +24,8 @@
|
||||||
use apsis_conflict::detect;
|
use apsis_conflict::detect;
|
||||||
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||||
use apsis_schedule::Schedule;
|
use apsis_schedule::Schedule;
|
||||||
use apsis_scheduler::Planner;
|
use apsis_scheduler::{ImprovementSettings, Planner};
|
||||||
use apsis_task::{Task, TaskId};
|
use apsis_task::{PrecedenceGraph, Task, TaskId};
|
||||||
|
|
||||||
/// The satellite resource.
|
/// The satellite resource.
|
||||||
const SATELLITE: ResourceId = ResourceId::new(0);
|
const SATELLITE: ResourceId = ResourceId::new(0);
|
||||||
|
|
@ -152,6 +153,70 @@ fn main() {
|
||||||
let again = Planner::new().schedule(&tasks, &resources);
|
let again = Planner::new().schedule(&tasks, &resources);
|
||||||
assert_eq!(result.schedule, again.schedule);
|
assert_eq!(result.schedule, again.schedule);
|
||||||
let _ = Schedule::new();
|
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");
|
println!("integration check passed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ apsis-core.workspace = true
|
||||||
apsis-resource.workspace = true
|
apsis-resource.workspace = true
|
||||||
apsis-task.workspace = true
|
apsis-task.workspace = true
|
||||||
apsis-schedule.workspace = true
|
apsis-schedule.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
apsis-conflict.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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<TaskId>,
|
||||||
|
/// The identifiers of the tasks the pass moved to make room.
|
||||||
|
pub moved: Vec<TaskId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TaskId> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,14 +44,16 @@
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
mod greedy;
|
mod greedy;
|
||||||
|
mod improve;
|
||||||
mod planner;
|
mod planner;
|
||||||
|
|
||||||
pub use greedy::{
|
pub use greedy::{
|
||||||
demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at,
|
demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at,
|
||||||
resources_have_capacity,
|
resources_have_capacity,
|
||||||
};
|
};
|
||||||
|
pub use improve::{ImprovementOutcome, ImprovementSettings, improve};
|
||||||
pub use planner::{PlanOutcome, Planner, SchedulingResult};
|
pub use planner::{PlanOutcome, Planner, SchedulingResult};
|
||||||
|
|
||||||
pub use apsis_resource::{ResourceId, ResourceSet};
|
pub use apsis_resource::{ResourceId, ResourceSet};
|
||||||
pub use apsis_schedule::Schedule;
|
pub use apsis_schedule::Schedule;
|
||||||
pub use apsis_task::{Task, TaskId};
|
pub use apsis_task::{PrecedenceGraph, Task, TaskId};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
//! The greedy planner.
|
//! The greedy planner.
|
||||||
|
|
||||||
use crate::greedy::{earliest_feasible_start, infeasible_tasks};
|
use crate::greedy::{earliest_feasible_start, infeasible_tasks};
|
||||||
|
use crate::improve::{ImprovementSettings, improve};
|
||||||
use apsis_resource::ResourceSet;
|
use apsis_resource::ResourceSet;
|
||||||
use apsis_schedule::Schedule;
|
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.
|
/// What happened to one task during planning.
|
||||||
///
|
///
|
||||||
|
|
@ -38,7 +39,17 @@ pub struct SchedulingResult {
|
||||||
/// The schedule that was produced.
|
/// The schedule that was produced.
|
||||||
pub schedule: Schedule,
|
pub schedule: Schedule,
|
||||||
/// The outcome of every task, in the order the planner considered them.
|
/// 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<PlanOutcome>,
|
pub outcomes: Vec<PlanOutcome>,
|
||||||
|
/// 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<TaskId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SchedulingResult {
|
impl SchedulingResult {
|
||||||
|
|
@ -84,12 +95,31 @@ impl SchedulingResult {
|
||||||
/// A plan that leaves a mandatory task unplaced is not usable, however
|
/// A plan that leaves a mandatory task unplaced is not usable, however
|
||||||
/// much optional work it found room for.
|
/// much optional work it found room for.
|
||||||
pub fn honours_mandatory(&self, tasks: &[Task]) -> bool {
|
pub fn honours_mandatory(&self, tasks: &[Task]) -> bool {
|
||||||
let mandatory: Vec<TaskId> = tasks
|
tasks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|task| task.mandatory)
|
.filter(|task| task.mandatory)
|
||||||
.map(|task| task.id)
|
.all(|task| self.schedule.is_placed(task.id))
|
||||||
.collect();
|
}
|
||||||
mandatory.iter().all(|id| self.schedule.is_placed(*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
|
/// left, while the reverse order could consume the only window the
|
||||||
/// mandatory task had.
|
/// mandatory task had.
|
||||||
pub mandatory_first: bool,
|
pub mandatory_first: bool,
|
||||||
|
/// The settings of the improvement pass.
|
||||||
|
pub improvement: ImprovementSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Planner {
|
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 {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mandatory_first: true,
|
mandatory_first: true,
|
||||||
|
improvement: ImprovementSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Planner {
|
impl Planner {
|
||||||
/// Creates a planner with the default settings.
|
/// 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 {
|
pub const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mandatory_first: true,
|
mandatory_first: true,
|
||||||
|
improvement: ImprovementSettings::new(4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,26 +171,61 @@ impl Planner {
|
||||||
self
|
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.
|
/// Returns the order in which the planner considers the tasks.
|
||||||
///
|
///
|
||||||
/// Mandatory tasks come first when the planner is configured that way,
|
/// Without a precedence graph the order is mandatory tasks first, when
|
||||||
/// each group ordered by [`task_order`], which is decreasing weighted
|
/// the planner is configured that way, and then decreasing weighted
|
||||||
/// value with ties broken by deadline and identifier.
|
/// 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<TaskId> {
|
pub fn order(&self, tasks: &[Task]) -> Vec<TaskId> {
|
||||||
let ordered = task_order(tasks);
|
self.order_with_precedence(tasks, None)
|
||||||
if !self.mandatory_first {
|
|
||||||
return ordered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<TaskId> {
|
||||||
|
let ordered = task_order(tasks);
|
||||||
|
let valued = if !self.mandatory_first {
|
||||||
|
ordered
|
||||||
|
} else {
|
||||||
let mandatory: Vec<TaskId> = tasks
|
let mandatory: Vec<TaskId> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|task| task.mandatory)
|
.filter(|task| task.mandatory)
|
||||||
.map(|task| task.id)
|
.map(|task| task.id)
|
||||||
.collect();
|
.collect();
|
||||||
// The stable partition keeps the relative order within each group.
|
|
||||||
let mut result = Vec::with_capacity(ordered.len());
|
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.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied());
|
result.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied());
|
||||||
result
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(graph) = precedence else {
|
||||||
|
return valued;
|
||||||
|
};
|
||||||
|
if graph.is_empty() {
|
||||||
|
return valued;
|
||||||
|
}
|
||||||
|
graph.topological_order(&valued).unwrap_or(valued)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plans a set of tasks on a set of resources.
|
/// 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
|
/// each placement is checked against the capacity before it is made. The
|
||||||
/// caller may confirm that with `apsis-conflict`.
|
/// caller may confirm that with `apsis-conflict`.
|
||||||
pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult {
|
pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult {
|
||||||
let mut schedule = Schedule::new();
|
self.plan(tasks, resources, None)
|
||||||
let mut outcomes = Vec::with_capacity(tasks.len());
|
}
|
||||||
|
|
||||||
// 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<TaskId> = 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<TaskId> = Vec::new();
|
||||||
let infeasible = infeasible_tasks(tasks, resources);
|
let infeasible = infeasible_tasks(tasks, resources);
|
||||||
|
|
||||||
for id in self.order(tasks) {
|
for id in order.drain(..) {
|
||||||
let task = tasks
|
let task = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.find(|task| task.id == id)
|
.find(|task| task.id == id)
|
||||||
|
|
@ -184,6 +294,13 @@ impl Planner {
|
||||||
} else {
|
} else {
|
||||||
format!("the resources cannot hold the demand of {id}")
|
format!("the resources cannot hold the demand of {id}")
|
||||||
};
|
};
|
||||||
|
set_state(
|
||||||
|
&mut states,
|
||||||
|
id,
|
||||||
|
TaskState::Infeasible {
|
||||||
|
reason: reason.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
outcomes.push(PlanOutcome {
|
outcomes.push(PlanOutcome {
|
||||||
task: id,
|
task: id,
|
||||||
state: TaskState::Infeasible { reason },
|
state: TaskState::Infeasible { reason },
|
||||||
|
|
@ -191,32 +308,144 @@ impl Planner {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match earliest_feasible_start(task, tasks, &schedule, resources) {
|
// A task may not start before the latest end among the tasks that
|
||||||
Some(start) => {
|
// must precede it.
|
||||||
// The placement was checked to be feasible, so the call
|
let earliest = match self.precedence_floor(id, tasks, precedence, &schedule, &states) {
|
||||||
// succeeds. The schedule may still refuse it if the task
|
Ok(floor) => floor,
|
||||||
// is already placed, which it is not, because each task
|
Err(reason) => {
|
||||||
// is considered once.
|
set_state(&mut states, id, TaskState::Deferred);
|
||||||
match schedule.place(task, start) {
|
outcomes.push(PlanOutcome {
|
||||||
Ok(_) => outcomes.push(PlanOutcome {
|
|
||||||
task: id,
|
|
||||||
state: TaskState::Scheduled { start },
|
|
||||||
}),
|
|
||||||
Err(error) => outcomes.push(PlanOutcome {
|
|
||||||
task: id,
|
|
||||||
state: TaskState::Infeasible {
|
|
||||||
reason: error.to_string(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => outcomes.push(PlanOutcome {
|
|
||||||
task: id,
|
task: id,
|
||||||
state: TaskState::Deferred,
|
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) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<Option<f64>, String> {
|
||||||
|
let Some(graph) = precedence else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let mut floor: Option<f64> = 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -148,10 +148,12 @@ fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() {
|
||||||
.expect("the mandatory task is placed");
|
.expect("the mandatory task is placed");
|
||||||
assert_eq!(mandatory_placement.start, 0.0);
|
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.
|
// mandatory one is placed after it, using the rest of its window.
|
||||||
let without = Planner::new()
|
let without = Planner::new()
|
||||||
.with_mandatory_first(false)
|
.with_mandatory_first(false)
|
||||||
|
.with_improvement(apsis_scheduler::ImprovementSettings::disabled())
|
||||||
.schedule(&tasks, &resources);
|
.schedule(&tasks, &resources);
|
||||||
let optional_placement = without
|
let optional_placement = without
|
||||||
.schedule
|
.schedule
|
||||||
|
|
@ -160,6 +162,40 @@ fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() {
|
||||||
assert_eq!(optional_placement.start, 0.0);
|
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]
|
#[test]
|
||||||
fn the_value_ordering_places_the_most_valuable_task_first() {
|
fn the_value_ordering_places_the_most_valuable_task_first() {
|
||||||
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
|
|
||||||
|
|
@ -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<Task> = (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<Task> = (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());
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ pub enum TaskErrorKind {
|
||||||
NoSuchTask,
|
NoSuchTask,
|
||||||
/// A task with the identifier already exists.
|
/// A task with the identifier already exists.
|
||||||
DuplicateTask,
|
DuplicateTask,
|
||||||
|
/// A precedence constraint would close a cycle.
|
||||||
|
PrecedenceCycle,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for TaskErrorKind {
|
impl fmt::Display for TaskErrorKind {
|
||||||
|
|
@ -35,6 +37,7 @@ impl fmt::Display for TaskErrorKind {
|
||||||
Self::InvalidWeight => "a priority or value is not finite",
|
Self::InvalidWeight => "a priority or value is not finite",
|
||||||
Self::NoSuchTask => "no such task",
|
Self::NoSuchTask => "no such task",
|
||||||
Self::DuplicateTask => "a task with that identifier exists",
|
Self::DuplicateTask => "a task with that identifier exists",
|
||||||
|
Self::PrecedenceCycle => "the precedence constraints contain a cycle",
|
||||||
};
|
};
|
||||||
f.write_str(text)
|
f.write_str(text)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
//! A task requires all of its resources at the same time, for its whole
|
//! 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
|
//! 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
|
//! 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
|
//! needs resources in sequence is expressed as two tasks joined by a
|
||||||
//! precedence constraint, which the scheduling layer provides.
|
//! 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
|
//! 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
|
//! kept separately, in the schedule, so that the same task can be considered
|
||||||
|
|
@ -37,9 +38,11 @@
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
|
mod precedence;
|
||||||
mod task;
|
mod task;
|
||||||
|
|
||||||
pub use error::{TaskError, TaskErrorKind};
|
pub use error::{TaskError, TaskErrorKind};
|
||||||
|
pub use precedence::PrecedenceGraph;
|
||||||
pub use task::{Requirement, Task, TaskId, TaskSet, TaskState, task_order};
|
pub use task::{Requirement, Task, TaskId, TaskSet, TaskState, task_order};
|
||||||
|
|
||||||
pub use apsis_resource::ResourceId;
|
pub use apsis_resource::ResourceId;
|
||||||
|
|
|
||||||
|
|
@ -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<usize, Vec<usize>>,
|
||||||
|
/// The predecessors of each task, in the order the edges were added.
|
||||||
|
predecessors: BTreeMap<usize, Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> = 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<TaskId> {
|
||||||
|
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<TaskId> {
|
||||||
|
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<TaskId> {
|
||||||
|
// 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<usize> = 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<TaskId> {
|
||||||
|
let mut leaves: Vec<usize> = 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<Vec<TaskId>, TaskError> {
|
||||||
|
// The position of each task in the supplied list, which breaks ties.
|
||||||
|
let mut preference: BTreeMap<usize, usize> = 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<usize, usize> = 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<usize> = Vec::with_capacity(tasks.len());
|
||||||
|
let mut placed: Vec<usize> = 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<usize> = self.successors.keys().copied().collect();
|
||||||
|
for task in self.predecessors.keys() {
|
||||||
|
if !all.contains(task) {
|
||||||
|
all.push(*task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue