From 46afd88f7e4d9a3f660f0f9c55a8494b4765ab55 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Wed, 30 Sep 2026 18:57:18 +0800 Subject: [PATCH] scheduler: read placed demands through the shared index The confirmation that follows the sweep called the public feasibility test, which scans the task list once to collect the boundaries and again, per boundary, to sum the demand of every covering placement. That is quadratic per task and dominated the search: removing the confirmation alone cut the crowded case by eight, which located the cost. The index built for the sweep is now passed into that test, so both the boundary collection and the demand sum read a task by its identifier instead of searching for it. The public entry point keeps its signature and builds the index for a caller that has none. Planning three thousand two hundred mutually contending tasks falls from 6.7 seconds to 0.48, and the plans are identical: the check compares the start time and end of every placement, before and after, across two resources with disjoint availability windows. --- crates/ops/apsis-scheduler/src/greedy.rs | 30 ++++++++++++++++-------- crates/ops/apsis-scheduler/src/sweep.rs | 21 +++++++---------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/crates/ops/apsis-scheduler/src/greedy.rs b/crates/ops/apsis-scheduler/src/greedy.rs index 0e2acd7..405b279 100644 --- a/crates/ops/apsis-scheduler/src/greedy.rs +++ b/crates/ops/apsis-scheduler/src/greedy.rs @@ -94,6 +94,24 @@ pub fn feasible_at( schedule: &Schedule, resources: &ResourceSet, start: f64, +) -> bool { + let index = crate::sweep::demand_index(tasks); + feasible_at_indexed(task, schedule, resources, start, &index) +} + +/// Returns `true` when a task placed at a time is feasible, using a demand +/// index the caller built once. +/// +/// The index replaces two linear scans of the task list: the one that finds a +/// placed task while collecting the boundaries, and the one inside that sums +/// the demand over every placement covering a boundary. The boundary +/// arithmetic is unchanged, so the answer is the same as the scans give. +pub(crate) fn feasible_at_indexed( + task: &Task, + schedule: &Schedule, + resources: &ResourceSet, + start: f64, + index: &crate::sweep::DemandIndex, ) -> bool { if !start.is_finite() { return false; @@ -112,10 +130,7 @@ pub fn feasible_at( // overlapping placements, where the total is greatest. let mut boundaries: Vec = 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() { + if crate::sweep::demand_on(index, placement.task, resource.id).is_none() { continue; } if placement.start < end && start < placement.end() { @@ -127,12 +142,7 @@ pub fn feasible_at( .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)) - }) + .filter_map(|placement| crate::sweep::demand_on(index, placement.task, resource.id)) .sum(); if placed + requirement.demand > resource.capacity() + 1e-9 { return false; diff --git a/crates/ops/apsis-scheduler/src/sweep.rs b/crates/ops/apsis-scheduler/src/sweep.rs index e81780e..d97acfd 100644 --- a/crates/ops/apsis-scheduler/src/sweep.rs +++ b/crates/ops/apsis-scheduler/src/sweep.rs @@ -20,7 +20,7 @@ //! capacity boundary. The fast path is therefore an accelerator, never a //! second definition of what is feasible. -use crate::greedy::{feasible_at, resources_have_capacity}; +use crate::greedy::{feasible_at_indexed, resources_have_capacity}; use apsis_resource::ResourceId; use apsis_resource::{Resource, ResourceSet}; use apsis_schedule::Schedule; @@ -68,7 +68,7 @@ pub fn earliest_feasible_start( // The sweep, confirmed by the direct test. if let Some(candidate) = swept_earliest_start(task, schedule, resources, latest_start, &demands) { - if feasible_at(task, tasks, schedule, resources, candidate) { + if feasible_at_indexed(task, schedule, resources, candidate, &demands) { return Some(candidate); } } @@ -76,7 +76,7 @@ pub fn earliest_feasible_start( // The enumeration, which is exact. It runs when the sweep could not // produce a confirmed answer, which is when the task does not fit at all // or when rounding made the two disagree. - enumerate_earliest_start(task, tasks, schedule, resources, latest_start) + enumerate_earliest_start(task, schedule, resources, latest_start, &demands) } /// Returns the earliest start by testing every candidate in turn. @@ -84,10 +84,10 @@ pub fn earliest_feasible_start( /// This is the original formulation, kept as the fallback for the sweep. fn enumerate_earliest_start( task: &Task, - tasks: &[Task], schedule: &Schedule, resources: &ResourceSet, latest_start: f64, + index: &DemandIndex, ) -> Option { let mut candidates: Vec = vec![task.earliest_start]; for requirement in &task.requirements { @@ -96,10 +96,7 @@ fn enumerate_earliest_start( 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()); + let overlaps = demand_on(index, placement.task, resource.id).is_some(); if overlaps { candidates.push(placement.end()); } @@ -117,7 +114,7 @@ fn enumerate_earliest_start( candidates .into_iter() - .find(|start| feasible_at(task, tasks, schedule, resources, *start)) + .find(|start| feasible_at_indexed(task, schedule, resources, *start, index)) } /// Returns the earliest start derived from the swept profile. @@ -264,10 +261,10 @@ fn blocked_intervals( /// vector indexed by it rather than in a map. Reading one is then an array /// index rather than a hash, and a hash of a small integer costs more than /// the scan it replaces. -type DemandIndex = Vec>>; +pub(crate) type DemandIndex = Vec>>; /// Builds a [`DemandIndex`] in one pass over the task list. -fn demand_index(tasks: &[Task]) -> DemandIndex { +pub(crate) fn demand_index(tasks: &[Task]) -> DemandIndex { let highest = tasks.iter().map(|task| task.id.index()).max().unwrap_or(0); let mut index: DemandIndex = (0..=highest).map(|_| None).collect(); for task in tasks { @@ -285,7 +282,7 @@ fn demand_index(tasks: &[Task]) -> DemandIndex { } /// Returns the demand a placed task puts on a resource, if any. -fn demand_on(index: &DemandIndex, task: TaskId, resource: ResourceId) -> Option { +pub(crate) fn demand_on(index: &DemandIndex, task: TaskId, resource: ResourceId) -> Option { index .get(task.index()) .and_then(Option::as_ref)