apsis-rs/crates/ops/apsis-scheduler/src/planner.rs

453 lines
17 KiB
Rust

//! The greedy planner.
use crate::greedy::infeasible_tasks;
use crate::improve::{ImprovementSettings, improve};
use crate::sweep::earliest_feasible_start;
use apsis_resource::ResourceSet;
use apsis_schedule::Schedule;
use apsis_task::{PrecedenceGraph, Task, TaskId, TaskState, task_order};
/// What happened to one task during planning.
///
/// The outcome pairs a task with the state the planner assigned, which is the
/// information an operator needs to see. It is separate from the schedule
/// because a task that was not placed has no placement but still has an
/// outcome worth reporting.
#[derive(Debug, Clone, PartialEq)]
pub struct PlanOutcome {
/// The task.
pub task: TaskId,
/// The state the planner assigned.
pub state: TaskState,
}
impl PlanOutcome {
/// Returns `true` when the task was placed.
pub fn is_scheduled(&self) -> bool {
self.state.is_scheduled()
}
/// Returns `true` when the task could not be placed for a structural
/// reason, rather than being left out for want of time.
pub fn is_infeasible(&self) -> bool {
matches!(self.state, TaskState::Infeasible { .. })
}
}
/// The result of planning.
#[derive(Debug, Clone, PartialEq)]
pub struct SchedulingResult {
/// The schedule that was produced.
pub schedule: Schedule,
/// The outcome of every task, in the order the planner considered them.
///
/// The order is the order of the task list the planner was given, not the
/// order in which the planner placed them, so that a caller can index the
/// outcomes by the position of the task in its own list.
pub outcomes: Vec<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 {
/// Returns the outcome of a task.
pub fn outcome_of(&self, task: TaskId) -> Option<&PlanOutcome> {
self.outcomes.iter().find(|outcome| outcome.task == task)
}
/// Returns the identifiers of the tasks that were placed.
pub fn scheduled(&self) -> Vec<TaskId> {
self.outcomes
.iter()
.filter(|outcome| outcome.is_scheduled())
.map(|outcome| outcome.task)
.collect()
}
/// Returns the identifiers of the tasks that were left out.
pub fn deferred(&self) -> Vec<TaskId> {
self.outcomes
.iter()
.filter(|outcome| outcome.state == TaskState::Deferred)
.map(|outcome| outcome.task)
.collect()
}
/// Returns the identifiers of the tasks that cannot be placed at all.
pub fn infeasible(&self) -> Vec<TaskId> {
self.outcomes
.iter()
.filter(|outcome| outcome.is_infeasible())
.map(|outcome| outcome.task)
.collect()
}
/// Returns `true` when every offered task was placed.
pub fn is_complete(&self) -> bool {
self.outcomes.iter().all(PlanOutcome::is_scheduled)
}
/// Returns `true` when every mandatory task was placed.
///
/// A plan that leaves a mandatory task unplaced is not usable, however
/// much optional work it found room for.
pub fn honours_mandatory(&self, tasks: &[Task]) -> bool {
tasks
.iter()
.filter(|task| task.mandatory)
.all(|task| self.schedule.is_placed(task.id))
}
/// Returns `true` when every precedence constraint is satisfied.
///
/// A constraint is satisfied when the second task starts at or after the
/// end of the first. A constraint whose tasks are not both placed is
/// trivially satisfied, because nothing has been promised about them.
pub fn honours_precedence(&self, graph: &PrecedenceGraph) -> bool {
self.outcomes
.iter()
.filter(|outcome| outcome.is_scheduled())
.all(|outcome| {
let Some(after) = self.schedule.placement_of(outcome.task) else {
return true;
};
graph.predecessors_of(outcome.task).iter().all(|before| {
self.schedule
.placement_of(*before)
.is_none_or(|placement| placement.end() <= after.start)
})
})
}
}
/// A greedy planner.
///
/// The planner holds the settings of the search. The settings are separated
/// from the schedule so that the same planner can be applied to many task
/// sets with the same policy.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Planner {
/// Whether to place mandatory tasks before the others regardless of
/// their value.
///
/// A mandatory task must run, so it is worth placing first even when its
/// weighted value is low: the optional tasks can use whatever time is
/// left, while the reverse order could consume the only window the
/// mandatory task had.
pub mandatory_first: bool,
/// The settings of the improvement pass.
pub improvement: ImprovementSettings,
}
impl Default for Planner {
/// Returns a planner that places mandatory tasks first and runs the
/// improvement pass.
fn default() -> Self {
Self {
mandatory_first: true,
improvement: ImprovementSettings::default(),
}
}
}
impl Planner {
/// Creates a planner with the default settings.
///
/// The defaults are the ones [`Planner::default`] uses: mandatory tasks
/// first and the improvement pass enabled with four attempts per task.
pub const fn new() -> Self {
Self {
mandatory_first: true,
improvement: ImprovementSettings::new(4),
}
}
/// Returns a copy of this planner with the mandatory placement rule set.
pub const fn with_mandatory_first(mut self, mandatory_first: bool) -> Self {
self.mandatory_first = mandatory_first;
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.
///
/// Without a precedence graph the order is mandatory tasks first, when
/// the planner is configured that way, and then decreasing weighted
/// value with ties broken by deadline and identifier.
///
/// With a precedence graph the order is a topological order of the
/// graph, using the value order to break ties. That places a task as
/// early as its constraints allow and the most valuable one first among
/// the tasks that are free.
pub fn order(&self, tasks: &[Task]) -> Vec<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 valued = if !self.mandatory_first {
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;
}
graph.topological_order(&valued).unwrap_or(valued)
}
/// Plans a set of tasks on a set of resources.
///
/// Every task is considered once, in the order the planner chooses. A
/// task is placed at the earliest time at which all of its resources are
/// free together. A task that cannot be placed is reported as deferred,
/// or as infeasible when no placement could ever exist.
///
/// The schedule that is returned is feasible by construction, because
/// each placement is checked against the capacity before it is made. The
/// caller may confirm that with `apsis-conflict`.
pub fn schedule(&self, tasks: &[Task], resources: &ResourceSet) -> SchedulingResult {
self.plan(tasks, resources, None)
}
/// 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);
for id in order.drain(..) {
let task = tasks
.iter()
.find(|task| task.id == id)
.expect("the order was built from the task list");
if infeasible.contains(&id) {
let reason = if !task.is_valid() {
"the task is not usable".to_string()
} else {
format!("the resources cannot hold the demand of {id}")
};
set_state(
&mut states,
id,
TaskState::Infeasible {
reason: reason.clone(),
},
);
outcomes.push(PlanOutcome {
task: id,
state: TaskState::Infeasible { reason },
});
continue;
}
// A task may not start before the latest end among the tasks that
// must precede it.
let earliest = match self.precedence_floor(id, tasks, precedence, &schedule, &states) {
Ok(floor) => floor,
Err(reason) => {
set_state(&mut states, id, TaskState::Deferred);
outcomes.push(PlanOutcome {
task: id,
state: TaskState::Deferred,
});
let _ = reason;
deferred.push(id);
continue;
}
};
// The task with the floor applied, so that the search respects
// the constraints.
let constrained = match earliest {
Some(floor) if floor > task.earliest_start => {
let mut copy = task.clone();
copy.earliest_start = floor;
copy
}
_ => task.clone(),
};
match earliest_feasible_start(&constrained, tasks, &schedule, resources) {
Some(start) => match schedule.place(task, start) {
Ok(_) => {
set_state(&mut states, id, TaskState::Scheduled { start });
outcomes.push(PlanOutcome {
task: id,
state: TaskState::Scheduled { start },
});
}
Err(error) => {
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);
}
}
}
// 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;
}
}