ops: add precedence constraints and an improvement pass

This commit is contained in:
ApsisRS contributors 2026-09-26 22:21:20 +08:00
parent ea5da41880
commit 436ebcc6f9
12 changed files with 1421 additions and 51 deletions

1
Cargo.lock generated
View File

@ -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",

View File

@ -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");
} }

View File

@ -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

View File

@ -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;
}
}

View File

@ -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};

View File

@ -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> {
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<TaskId> {
let ordered = task_order(tasks); let ordered = task_order(tasks);
if !self.mandatory_first { let valued = if !self.mandatory_first {
return ordered; ordered
} else {
let mandatory: Vec<TaskId> = 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<TaskId> = tasks graph.topological_order(&valued).unwrap_or(valued)
.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
} }
/// 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::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, task: id,
state: TaskState::Scheduled { start }, 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<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;
} }
} }

View File

@ -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");

View File

@ -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());
}

View File

@ -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)
} }

View File

@ -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;

View File

@ -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()
}
}

View File

@ -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)));
}