256 lines
8.9 KiB
Rust
256 lines
8.9 KiB
Rust
//! 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;
|
|
}
|
|
}
|