//! 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, /// The identifiers of the tasks the pass moved to make room. pub moved: Vec, } 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 { 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; } }