ops: add greedy task planner
This commit is contained in:
parent
f8fd175266
commit
a83df0333a
|
|
@ -281,6 +281,12 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-scheduler"
|
name = "apsis-scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"apsis-core",
|
||||||
|
"apsis-resource",
|
||||||
|
"apsis-schedule",
|
||||||
|
"apsis-task",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apsis-sgp4"
|
name = "apsis-sgp4"
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,7 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-resource.workspace = true
|
||||||
|
apsis-task.workspace = true
|
||||||
|
apsis-schedule.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
//! The building blocks a greedy scheduler needs.
|
||||||
|
|
||||||
|
use apsis_resource::{ResourceId, ResourceSet};
|
||||||
|
use apsis_schedule::Schedule;
|
||||||
|
use apsis_task::{Task, TaskId};
|
||||||
|
|
||||||
|
/// Returns `true` when every resource of a task has the capacity to hold its
|
||||||
|
/// demand.
|
||||||
|
///
|
||||||
|
/// A task that demands more of a resource than the resource has can never be
|
||||||
|
/// scheduled on it, so this is checked before the search for a time. Returns
|
||||||
|
/// `false` when a required resource does not exist.
|
||||||
|
pub fn resources_have_capacity(task: &Task, resources: &ResourceSet) -> bool {
|
||||||
|
task.requirements.iter().all(|requirement| {
|
||||||
|
resources
|
||||||
|
.get(requirement.resource)
|
||||||
|
.is_some_and(|resource| requirement.demand <= resource.capacity() + 1e-9)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total demand placed on each resource at an instant, given a
|
||||||
|
/// schedule and the tasks it contains.
|
||||||
|
///
|
||||||
|
/// The result is in the order the resources appear in the set. A resource
|
||||||
|
/// that is not in the set is omitted.
|
||||||
|
pub fn demand_at(
|
||||||
|
schedule: &Schedule,
|
||||||
|
tasks: &[Task],
|
||||||
|
resources: &ResourceSet,
|
||||||
|
time: f64,
|
||||||
|
) -> Vec<(ResourceId, f64)> {
|
||||||
|
resources
|
||||||
|
.resources()
|
||||||
|
.iter()
|
||||||
|
.map(|resource| {
|
||||||
|
let total: f64 = schedule
|
||||||
|
.placements()
|
||||||
|
.iter()
|
||||||
|
.filter(|placement| placement.covers(time))
|
||||||
|
.filter_map(|placement| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|task| task.id == placement.task)
|
||||||
|
.and_then(|task| task.demand_for(resource.id))
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
(resource.id, total)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifiers of the resources that are free at an instant.
|
||||||
|
///
|
||||||
|
/// A resource is free when its availability windows cover the instant and
|
||||||
|
/// the demand already placed on it leaves room for one more unit. This is
|
||||||
|
/// the query a watermark scheduler makes when it looks for a satellite with
|
||||||
|
/// spare capacity.
|
||||||
|
pub fn resources_free_at(
|
||||||
|
schedule: &Schedule,
|
||||||
|
tasks: &[Task],
|
||||||
|
resources: &ResourceSet,
|
||||||
|
time: f64,
|
||||||
|
) -> Vec<ResourceId> {
|
||||||
|
resources
|
||||||
|
.resources()
|
||||||
|
.iter()
|
||||||
|
.filter(|resource| resource.is_available_at(time))
|
||||||
|
.filter(|resource| {
|
||||||
|
let placed: f64 = schedule
|
||||||
|
.placements()
|
||||||
|
.iter()
|
||||||
|
.filter(|placement| placement.covers(time))
|
||||||
|
.filter_map(|placement| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|task| task.id == placement.task)
|
||||||
|
.and_then(|task| task.demand_for(resource.id))
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
placed < resource.capacity() - 1e-9
|
||||||
|
})
|
||||||
|
.map(|resource| resource.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the earliest time at which a task can be placed.
|
||||||
|
///
|
||||||
|
/// The earliest feasible start is always one of a small set of candidate
|
||||||
|
/// times: the start of the task's window, the start of an availability
|
||||||
|
/// window of one of its resources, or the end of a placement already on one
|
||||||
|
/// of those resources. Feasibility cannot change at any other instant,
|
||||||
|
/// because the demand on every resource is constant between those events and
|
||||||
|
/// the availability is constant between them too. Enumerating that set and
|
||||||
|
/// taking the smallest feasible member therefore finds the true earliest
|
||||||
|
/// start.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the task cannot be placed anywhere in its window.
|
||||||
|
pub fn earliest_feasible_start(
|
||||||
|
task: &Task,
|
||||||
|
tasks: &[Task],
|
||||||
|
schedule: &Schedule,
|
||||||
|
resources: &ResourceSet,
|
||||||
|
) -> Option<f64> {
|
||||||
|
if !task.is_valid() || !resources_have_capacity(task, resources) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if task.requirements.is_empty() {
|
||||||
|
// A task with no requirements can start as soon as its window opens.
|
||||||
|
return Some(task.earliest_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The candidate times, collected from every event that can change
|
||||||
|
// feasibility.
|
||||||
|
let mut candidates: Vec<f64> = vec![task.earliest_start];
|
||||||
|
for requirement in &task.requirements {
|
||||||
|
let resource = resources.get(requirement.resource)?;
|
||||||
|
for window in resource.windows() {
|
||||||
|
candidates.push(window.start());
|
||||||
|
}
|
||||||
|
for placement in schedule.placements() {
|
||||||
|
let overlaps = tasks
|
||||||
|
.iter()
|
||||||
|
.find(|other| other.id == placement.task)
|
||||||
|
.is_some_and(|other| other.demand_for(resource.id).is_some());
|
||||||
|
if overlaps {
|
||||||
|
candidates.push(placement.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only times at or after the window opens, and no later than the last
|
||||||
|
// start that could still fit, are worth testing.
|
||||||
|
let latest_start = task.latest_end - task.duration;
|
||||||
|
candidates
|
||||||
|
.retain(|time| time.is_finite() && *time >= task.earliest_start && *time <= latest_start);
|
||||||
|
candidates.sort_by(|first, second| {
|
||||||
|
first
|
||||||
|
.partial_cmp(second)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
candidates.dedup();
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|start| feasible_at(task, tasks, schedule, resources, *start))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when a task placed at a time is feasible.
|
||||||
|
///
|
||||||
|
/// The check is that every required resource is available for the whole
|
||||||
|
/// duration and that the demand already placed on it, plus the new demand,
|
||||||
|
/// stays within the capacity at every instant of the placement.
|
||||||
|
pub fn feasible_at(
|
||||||
|
task: &Task,
|
||||||
|
tasks: &[Task],
|
||||||
|
schedule: &Schedule,
|
||||||
|
resources: &ResourceSet,
|
||||||
|
start: f64,
|
||||||
|
) -> bool {
|
||||||
|
if !start.is_finite() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let end = start + task.duration;
|
||||||
|
for requirement in &task.requirements {
|
||||||
|
let Some(resource) = resources.get(requirement.resource) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !resource.is_available_over(start, task.duration) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The demand of the new task must fit alongside the placements that
|
||||||
|
// overlap it. Because the demand of each placement is constant over
|
||||||
|
// its own interval, it is enough to test the boundaries of the
|
||||||
|
// overlapping placements, where the total is greatest.
|
||||||
|
let mut boundaries: Vec<f64> = vec![start];
|
||||||
|
for placement in schedule.placements() {
|
||||||
|
let Some(other) = tasks.iter().find(|other| other.id == placement.task) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if other.demand_for(resource.id).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if placement.start < end && start < placement.end() {
|
||||||
|
boundaries.push(placement.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for boundary in boundaries {
|
||||||
|
let placed: f64 = schedule
|
||||||
|
.placements()
|
||||||
|
.iter()
|
||||||
|
.filter(|placement| placement.covers(boundary))
|
||||||
|
.filter_map(|placement| {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.find(|other| other.id == placement.task)
|
||||||
|
.and_then(|other| other.demand_for(resource.id))
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if placed + requirement.demand > resource.capacity() + 1e-9 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifiers of the tasks that a set of resources cannot
|
||||||
|
/// accommodate at all.
|
||||||
|
///
|
||||||
|
/// A task is infeasible when it demands more of a resource than the resource
|
||||||
|
/// has, when a resource it requires does not exist, or when its window is
|
||||||
|
/// shorter than its duration. These cannot be repaired by rescheduling and
|
||||||
|
/// are worth reporting separately from the tasks that were merely left out.
|
||||||
|
pub fn infeasible_tasks(tasks: &[Task], resources: &ResourceSet) -> Vec<TaskId> {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| !task.is_valid() || !resources_have_capacity(task, resources))
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
@ -1 +1,57 @@
|
||||||
//! Scheduling algorithms for satellite task planning.
|
//! Scheduling algorithms for satellite task planning.
|
||||||
|
//!
|
||||||
|
//! A scheduler takes a list of tasks and a set of resources and produces a
|
||||||
|
//! schedule. This crate provides a greedy scheduler and the pieces needed to
|
||||||
|
//! build others.
|
||||||
|
//!
|
||||||
|
//! Scheduling with resource constraints is intractable in general: deciding
|
||||||
|
//! whether every task fits is a bin packing problem, which is NP-hard. A
|
||||||
|
//! satellite planning problem is small, with hundreds of tasks rather than
|
||||||
|
//! millions, but an exact search is still impractical for a routine planning
|
||||||
|
//! cycle. The scheduler here is therefore a greedy heuristic.
|
||||||
|
//!
|
||||||
|
//! The algorithm sorts the tasks and places each in the earliest time at
|
||||||
|
//! which all of its resources are free together. Tasks are considered in
|
||||||
|
//! decreasing order of weighted value, which is a good heuristic: the most
|
||||||
|
//! valuable work is placed first and the least valuable fills the remaining
|
||||||
|
//! gaps. Mandatory tasks are placed first regardless of their value, because
|
||||||
|
//! a task that must run is worth more than any task that may.
|
||||||
|
//!
|
||||||
|
//! The result is not guaranteed to be optimal, and the crate reports what it
|
||||||
|
//! could not place rather than dropping it silently, so that an operator can
|
||||||
|
//! see the cost of the plan.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||||
|
//! use apsis_scheduler::Planner;
|
||||||
|
//! use apsis_task::{Task, TaskId};
|
||||||
|
//!
|
||||||
|
//! let mut station = Resource::new(ResourceId::new(0), 1.0);
|
||||||
|
//! station.set_windows(&[Window::new(0.0, 1_000.0)]).expect("usable");
|
||||||
|
//! let resources = ResourceSet::from_resources(vec![station]).expect("distinct");
|
||||||
|
//!
|
||||||
|
//! let tasks = vec![Task::new(TaskId::new(0), 60.0)
|
||||||
|
//! .try_require(ResourceId::new(0), 1.0)
|
||||||
|
//! .expect("the resource is new")];
|
||||||
|
//!
|
||||||
|
//! let planner = Planner::new();
|
||||||
|
//! let result = planner.schedule(&tasks, &resources);
|
||||||
|
//! assert_eq!(result.schedule.len(), 1);
|
||||||
|
//! assert!(result.is_complete());
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod greedy;
|
||||||
|
mod planner;
|
||||||
|
|
||||||
|
pub use greedy::{
|
||||||
|
demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at,
|
||||||
|
resources_have_capacity,
|
||||||
|
};
|
||||||
|
pub use planner::{PlanOutcome, Planner, SchedulingResult};
|
||||||
|
|
||||||
|
pub use apsis_resource::{ResourceId, ResourceSet};
|
||||||
|
pub use apsis_schedule::Schedule;
|
||||||
|
pub use apsis_task::{Task, TaskId};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
//! The greedy planner.
|
||||||
|
|
||||||
|
use crate::greedy::{earliest_feasible_start, infeasible_tasks};
|
||||||
|
use apsis_resource::ResourceSet;
|
||||||
|
use apsis_schedule::Schedule;
|
||||||
|
use apsis_task::{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.
|
||||||
|
pub outcomes: Vec<PlanOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let mandatory: Vec<TaskId> = tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.mandatory)
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect();
|
||||||
|
mandatory.iter().all(|id| self.schedule.is_placed(*id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Planner {
|
||||||
|
/// Returns a planner that places mandatory tasks first.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mandatory_first: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Planner {
|
||||||
|
/// Creates a planner with the default settings.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
mandatory_first: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 the order in which the planner considers the tasks.
|
||||||
|
///
|
||||||
|
/// Mandatory tasks come first when the planner is configured that way,
|
||||||
|
/// each group ordered by [`task_order`], which is decreasing weighted
|
||||||
|
/// value with ties broken by deadline and identifier.
|
||||||
|
pub fn order(&self, tasks: &[Task]) -> Vec<TaskId> {
|
||||||
|
let ordered = task_order(tasks);
|
||||||
|
if !self.mandatory_first {
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
let mandatory: Vec<TaskId> = tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.mandatory)
|
||||||
|
.map(|task| task.id)
|
||||||
|
.collect();
|
||||||
|
// The stable partition keeps the relative order within each group.
|
||||||
|
let mut result = Vec::with_capacity(ordered.len());
|
||||||
|
result.extend(ordered.iter().filter(|id| mandatory.contains(id)).copied());
|
||||||
|
result.extend(ordered.iter().filter(|id| !mandatory.contains(id)).copied());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let mut schedule = Schedule::new();
|
||||||
|
let mut outcomes = Vec::with_capacity(tasks.len());
|
||||||
|
|
||||||
|
// Tasks that cannot be placed at all are reported without a search.
|
||||||
|
let infeasible = infeasible_tasks(tasks, resources);
|
||||||
|
|
||||||
|
for id in self.order(tasks) {
|
||||||
|
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}")
|
||||||
|
};
|
||||||
|
outcomes.push(PlanOutcome {
|
||||||
|
task: id,
|
||||||
|
state: TaskState::Infeasible { reason },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match earliest_feasible_start(task, tasks, &schedule, resources) {
|
||||||
|
Some(start) => {
|
||||||
|
// The placement was checked to be feasible, so the call
|
||||||
|
// succeeds. The schedule may still refuse it if the task
|
||||||
|
// is already placed, which it is not, because each task
|
||||||
|
// is considered once.
|
||||||
|
match schedule.place(task, start) {
|
||||||
|
Ok(_) => outcomes.push(PlanOutcome {
|
||||||
|
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,
|
||||||
|
state: TaskState::Deferred,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SchedulingResult { schedule, outcomes }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
//! Tests for the greedy planner.
|
||||||
|
|
||||||
|
use apsis_resource::{Resource, ResourceId, ResourceSet, Window};
|
||||||
|
use apsis_schedule::Schedule;
|
||||||
|
use apsis_scheduler::{
|
||||||
|
Planner, demand_at, earliest_feasible_start, feasible_at, infeasible_tasks, resources_free_at,
|
||||||
|
resources_have_capacity,
|
||||||
|
};
|
||||||
|
use apsis_task::{Task, TaskId, TaskState};
|
||||||
|
|
||||||
|
/// 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_single_task_is_placed_at_the_opening_of_its_window() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [task(0, 60.0, &[0])];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert_eq!(result.schedule.len(), 1);
|
||||||
|
assert!(result.is_complete());
|
||||||
|
let placement = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(0))
|
||||||
|
.expect("placed");
|
||||||
|
// The resource is available from zero, so the task starts there.
|
||||||
|
assert_eq!(placement.start, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tasks_on_one_exclusive_resource_are_placed_back_to_back() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
// Equal value, so the order falls back to the earlier deadline and then
|
||||||
|
// the identifier.
|
||||||
|
let tasks = [task(0, 60.0, &[0]), task(1, 30.0, &[0])];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(result.is_complete());
|
||||||
|
let first = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(0))
|
||||||
|
.expect("placed");
|
||||||
|
let second = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(1))
|
||||||
|
.expect("placed");
|
||||||
|
// The two must not overlap: the second starts at or after the end of the
|
||||||
|
// first.
|
||||||
|
assert!(second.start >= first.end(), "{second:?} against {first:?}");
|
||||||
|
assert!(!result.schedule.has_overlapping_placements());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_that_does_not_fit_is_deferred() {
|
||||||
|
// The resource is available for only sixty seconds, so a task of ninety
|
||||||
|
// cannot be placed.
|
||||||
|
let mut limited = Resource::new(ResourceId::new(0), 1.0);
|
||||||
|
limited
|
||||||
|
.set_windows(&[Window::new(0.0, 60.0)])
|
||||||
|
.expect("the window is usable");
|
||||||
|
let resources = ResourceSet::from_resources(vec![limited]).expect("one");
|
||||||
|
let tasks = [task(0, 90.0, &[0])];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(!result.is_complete());
|
||||||
|
assert_eq!(result.deferred(), vec![TaskId::new(0)]);
|
||||||
|
assert_eq!(
|
||||||
|
result.outcome_of(TaskId::new(0)).expect("reported").state,
|
||||||
|
TaskState::Deferred
|
||||||
|
);
|
||||||
|
assert!(result.schedule.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_demanding_more_than_the_capacity_is_infeasible() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [Task::new(TaskId::new(0), 60.0)
|
||||||
|
.try_require(ResourceId::new(0), 2.0)
|
||||||
|
.expect("the resource is new")];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert_eq!(result.infeasible(), vec![TaskId::new(0)]);
|
||||||
|
assert!(result.schedule.is_empty());
|
||||||
|
assert!(!result.is_complete());
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.outcome_of(TaskId::new(0))
|
||||||
|
.expect("reported")
|
||||||
|
.is_infeasible()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_requiring_a_missing_resource_is_infeasible() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [task(0, 60.0, &[9])];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert_eq!(result.infeasible(), vec![TaskId::new(0)]);
|
||||||
|
assert!(result.schedule.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_with_a_window_too_short_is_infeasible() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [task(0, 90.0, &[0]).with_window(0.0, 60.0)];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert_eq!(result.infeasible(), vec![TaskId::new(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mandatory_task_is_placed_before_a_more_valuable_optional_one() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
// The optional task is worth far more, but it can use whatever time is
|
||||||
|
// left, while the mandatory task has only one window.
|
||||||
|
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 result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(result.honours_mandatory(&tasks));
|
||||||
|
let mandatory_placement = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(1))
|
||||||
|
.expect("the mandatory task is placed");
|
||||||
|
assert_eq!(mandatory_placement.start, 0.0);
|
||||||
|
|
||||||
|
// With the rule turned off, the optional task goes first and the
|
||||||
|
// mandatory one is placed after it, using the rest of its window.
|
||||||
|
let without = Planner::new()
|
||||||
|
.with_mandatory_first(false)
|
||||||
|
.schedule(&tasks, &resources);
|
||||||
|
let optional_placement = without
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(0))
|
||||||
|
.expect("the optional task is placed");
|
||||||
|
assert_eq!(optional_placement.start, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_value_ordering_places_the_most_valuable_task_first() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [
|
||||||
|
task(0, 10.0, &[0]).with_priority(1.0),
|
||||||
|
task(1, 100.0, &[0]).with_priority(5.0),
|
||||||
|
task(2, 50.0, &[0]).with_priority(2.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(result.is_complete());
|
||||||
|
// The weighted values are 10, 500, and 100, so task 1 goes first.
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(1))
|
||||||
|
.expect("placed")
|
||||||
|
.start,
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_tasks_on_a_shared_resource_can_overlap() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 2.0)]).expect("one");
|
||||||
|
let tasks = [task(0, 60.0, &[0]), task(1, 60.0, &[0])];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(result.is_complete());
|
||||||
|
let first = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(0))
|
||||||
|
.expect("placed");
|
||||||
|
let second = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(1))
|
||||||
|
.expect("placed");
|
||||||
|
// Both start as early as possible, because the capacity holds them both.
|
||||||
|
assert_eq!(first.start, 0.0);
|
||||||
|
assert_eq!(second.start, 0.0);
|
||||||
|
assert!(result.schedule.has_overlapping_placements());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_task_is_placed_after_the_other_frees_its_resource() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
// The first task occupies the resource from 100 to 200, so the second,
|
||||||
|
// which can start no earlier than 100, must wait.
|
||||||
|
let first = task(0, 100.0, &[0]).with_window(100.0, 1_000.0);
|
||||||
|
let second = task(1, 50.0, &[0]).with_window(100.0, 1_000.0);
|
||||||
|
let tasks = [first, second];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert!(result.is_complete());
|
||||||
|
let first_placement = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(0))
|
||||||
|
.expect("placed");
|
||||||
|
let second_placement = result
|
||||||
|
.schedule
|
||||||
|
.placement_of(TaskId::new(1))
|
||||||
|
.expect("placed");
|
||||||
|
assert!(second_placement.start >= first_placement.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_earliest_start_is_the_false_of_the_window_when_nothing_blocks() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let schedule = Schedule::new();
|
||||||
|
let tasks = [task(0, 60.0, &[0]).with_window(300.0, 1_000.0)];
|
||||||
|
|
||||||
|
let start = earliest_feasible_start(&tasks[0], &tasks, &schedule, &resources)
|
||||||
|
.expect("a placement exists");
|
||||||
|
assert_eq!(start, 300.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_earliest_start_is_none_when_the_window_is_too_short() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let schedule = Schedule::new();
|
||||||
|
let tasks = [task(0, 90.0, &[0]).with_window(0.0, 60.0)];
|
||||||
|
assert_eq!(
|
||||||
|
earliest_feasible_start(&tasks[0], &tasks, &schedule, &resources),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_earliest_start_accounts_for_a_blocking_placement() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let blocker = task(0, 100.0, &[0]);
|
||||||
|
let mut schedule = Schedule::new();
|
||||||
|
schedule.place(&blocker, 0.0).expect("fits");
|
||||||
|
|
||||||
|
let candidate = task(1, 50.0, &[0]);
|
||||||
|
let tasks = [blocker, candidate.clone()];
|
||||||
|
let start = earliest_feasible_start(&candidate, &tasks, &schedule, &resources)
|
||||||
|
.expect("a placement exists");
|
||||||
|
// The resource is held until a hundred, so the earliest start is there.
|
||||||
|
assert_eq!(start, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feasibility_reports_the_capacity_limit() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let existing = task(0, 100.0, &[0]);
|
||||||
|
let mut schedule = Schedule::new();
|
||||||
|
schedule.place(&existing, 0.0).expect("fits");
|
||||||
|
|
||||||
|
let candidate = task(1, 50.0, &[0]);
|
||||||
|
let tasks = [existing, candidate.clone()];
|
||||||
|
// Starting at fifty would overlap the existing placement, which the
|
||||||
|
// capacity of one does not allow.
|
||||||
|
assert!(!feasible_at(
|
||||||
|
&candidate, &tasks, &schedule, &resources, 50.0
|
||||||
|
));
|
||||||
|
// Starting at a hundred is free.
|
||||||
|
assert!(feasible_at(
|
||||||
|
&candidate, &tasks, &schedule, &resources, 100.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feasibility_reports_the_availability_limit() {
|
||||||
|
let mut limited = Resource::new(ResourceId::new(0), 1.0);
|
||||||
|
limited
|
||||||
|
.set_windows(&[Window::new(0.0, 100.0)])
|
||||||
|
.expect("the window is usable");
|
||||||
|
let resources = ResourceSet::from_resources(vec![limited]).expect("one");
|
||||||
|
let schedule = Schedule::new();
|
||||||
|
let tasks = [task(0, 60.0, &[0])];
|
||||||
|
|
||||||
|
assert!(feasible_at(&tasks[0], &tasks, &schedule, &resources, 0.0));
|
||||||
|
assert!(feasible_at(&tasks[0], &tasks, &schedule, &resources, 40.0));
|
||||||
|
// Starting at fifty would end at one hundred and ten, past the window.
|
||||||
|
assert!(!feasible_at(&tasks[0], &tasks, &schedule, &resources, 50.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_capacity_check_covers_a_multiple_resource_task() {
|
||||||
|
let resources =
|
||||||
|
ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct");
|
||||||
|
// The task needs both resources at once.
|
||||||
|
let tasks = [task(0, 60.0, &[0, 1])];
|
||||||
|
assert!(resources_have_capacity(&tasks[0], &resources));
|
||||||
|
|
||||||
|
// The same task with one resource missing is infeasible.
|
||||||
|
let missing = [task(0, 60.0, &[0, 9])];
|
||||||
|
assert!(!resources_have_capacity(&missing[0], &resources));
|
||||||
|
assert_eq!(infeasible_tasks(&missing, &resources), vec![TaskId::new(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_demand_at_an_instant_is_reported_per_resource() {
|
||||||
|
let resources =
|
||||||
|
ResourceSet::from_resources(vec![resource(0, 2.0), resource(1, 1.0)]).expect("distinct");
|
||||||
|
let tasks = [task(0, 100.0, &[0]), task(1, 100.0, &[1])];
|
||||||
|
let mut schedule = Schedule::new();
|
||||||
|
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||||
|
schedule.place(&tasks[1], 50.0).expect("fits");
|
||||||
|
|
||||||
|
let demand = demand_at(&schedule, &tasks, &resources, 75.0);
|
||||||
|
assert_eq!(demand[0], (ResourceId::new(0), 1.0));
|
||||||
|
assert_eq!(demand[1], (ResourceId::new(1), 1.0));
|
||||||
|
// Before either placement the demand is zero.
|
||||||
|
let demand = demand_at(&schedule, &tasks, &resources, -1.0);
|
||||||
|
assert_eq!(demand[0].1, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_free_resources_at_an_instant_exclude_the_busy_ones() {
|
||||||
|
let resources =
|
||||||
|
ResourceSet::from_resources(vec![resource(0, 1.0), resource(1, 1.0)]).expect("distinct");
|
||||||
|
let tasks = [task(0, 100.0, &[0])];
|
||||||
|
let mut schedule = Schedule::new();
|
||||||
|
schedule.place(&tasks[0], 0.0).expect("fits");
|
||||||
|
|
||||||
|
// At fifty seconds resource zero is full and resource one is free.
|
||||||
|
assert_eq!(
|
||||||
|
resources_free_at(&schedule, &tasks, &resources, 50.0),
|
||||||
|
vec![ResourceId::new(1)]
|
||||||
|
);
|
||||||
|
// At a hundred and fifty both are free.
|
||||||
|
assert_eq!(
|
||||||
|
resources_free_at(&schedule, &tasks, &resources, 150.0),
|
||||||
|
vec![ResourceId::new(0), ResourceId::new(1)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_planner_order_puts_mandatory_tasks_first() {
|
||||||
|
let tasks = [
|
||||||
|
task(0, 10.0, &[0]).with_priority(10.0),
|
||||||
|
task(1, 10.0, &[0]).with_priority(1.0).with_mandatory(true),
|
||||||
|
];
|
||||||
|
let order = Planner::new().order(&tasks);
|
||||||
|
assert_eq!(order[0], TaskId::new(1));
|
||||||
|
|
||||||
|
let without = Planner::new().with_mandatory_first(false).order(&tasks);
|
||||||
|
assert_eq!(without[0], TaskId::new(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_result_reports_every_task() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [
|
||||||
|
task(0, 60.0, &[0]),
|
||||||
|
task(1, 90.0, &[0]).with_window(0.0, 60.0),
|
||||||
|
Task::new(TaskId::new(2), 60.0)
|
||||||
|
.try_require(ResourceId::new(0), 5.0)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
assert_eq!(result.outcomes.len(), 3);
|
||||||
|
for task in &tasks {
|
||||||
|
assert!(result.outcome_of(task.id).is_some());
|
||||||
|
}
|
||||||
|
// Only the first can be placed.
|
||||||
|
assert_eq!(result.scheduled(), vec![TaskId::new(0)]);
|
||||||
|
assert!(!result.is_complete());
|
||||||
|
assert!(result.honours_mandatory(&tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mandatory_task_that_cannot_be_placed_is_reported() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [task(0, 90.0, &[0])
|
||||||
|
.with_window(0.0, 60.0)
|
||||||
|
.with_mandatory(true)];
|
||||||
|
let result = Planner::new().schedule(&tasks, &resources);
|
||||||
|
// The plan does not honour the mandatory task, which the caller can
|
||||||
|
// detect.
|
||||||
|
assert!(!result.honours_mandatory(&tasks));
|
||||||
|
assert!(!result.is_complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn planning_an_empty_task_list_gives_an_empty_schedule() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let result = Planner::new().schedule(&[], &resources);
|
||||||
|
assert!(result.schedule.is_empty());
|
||||||
|
assert!(result.outcomes.is_empty());
|
||||||
|
assert!(result.is_complete());
|
||||||
|
assert!(result.honours_mandatory(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_planner_default_places_mandatory_tasks_first() {
|
||||||
|
assert_eq!(Planner::default(), Planner::new());
|
||||||
|
assert!(Planner::new().mandatory_first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_infeasible_task_list_excludes_the_merely_deferred() {
|
||||||
|
let resources = ResourceSet::from_resources(vec![resource(0, 1.0)]).expect("one");
|
||||||
|
let tasks = [
|
||||||
|
// Can be placed.
|
||||||
|
task(0, 60.0, &[0]),
|
||||||
|
// Demands too much.
|
||||||
|
Task::new(TaskId::new(1), 60.0)
|
||||||
|
.try_require(ResourceId::new(0), 5.0)
|
||||||
|
.expect("the resource is new"),
|
||||||
|
// Names a resource that does not exist.
|
||||||
|
task(2, 60.0, &[9]),
|
||||||
|
];
|
||||||
|
let infeasible = infeasible_tasks(&tasks, &resources);
|
||||||
|
assert_eq!(infeasible, vec![TaskId::new(1), TaskId::new(2)]);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue