elevator_core/dispatch/assignment.rs
1//! Hungarian-assignment pass and per-pass scratch buffers.
2//!
3//! This module owns the optimal-bipartite-matching machinery that
4//! consumes a [`DispatchStrategy`]'s `rank` outputs and turns them
5//! into one [`DispatchDecision`] per idle car. It's the bottom of
6//! the dispatch stack — strategies hand it scores, hosts hand it the
7//! manifest, and `systems::dispatch::run` calls it once per group
8//! per tick.
9
10use std::collections::HashSet;
11
12use crate::components::{ElevatorPhase, Route, TransportMode};
13use crate::entity::EntityId;
14use crate::world::World;
15
16use super::{
17 DispatchDecision, DispatchManifest, DispatchStrategy, ElevatorGroup, LineInfo, RankContext,
18};
19
20/// Resolution of a single dispatch assignment pass for one group.
21///
22/// Produced by `assign` and consumed by
23/// `crate::systems::dispatch::run` to apply decisions to the world.
24#[derive(Debug, Clone)]
25pub struct AssignmentResult {
26 /// `(car, decision)` pairs for every idle car in the group.
27 pub decisions: Vec<(EntityId, DispatchDecision)>,
28}
29
30/// Per-simulation scratch buffers for the dispatch phase.
31///
32/// Every field is a `Vec`/`HashSet` whose allocations the hot path
33/// would otherwise re-take on every tick per group (cost matrix
34/// backing store, pending-stops list, servicing cars, pinned /
35/// committed / idle-elevator filters). Owning them on the
36/// simulation lets each dispatch pass `clear()` them in place and
37/// reuse the capacity — on a 50-car × 200-stop group the cost matrix
38/// alone is ~80 KB of heap churn per tick, and at the 500-car
39/// `scaling_extreme` scale it's ~20 MB.
40///
41/// The scratch is always cleared before use; consumers should not
42/// rely on any carry-over content between groups or ticks.
43#[derive(Default)]
44pub struct DispatchScratch {
45 /// Reusable `Matrix<i64>` the Hungarian consumes by reference. When
46 /// the dispatch pass can reuse the stored Matrix (`rows × cols`
47 /// match), this is `Some` and gets filled in-place via `Matrix::fill`;
48 /// when shapes change the slot is replaced with `Matrix::new`.
49 pub cost_matrix_mx: Option<pathfinding::matrix::Matrix<i64>>,
50 /// `(stop, line, remaining_capacity)` for door-cycling cars, used
51 /// by `pending_stops_minus_covered` to avoid double-dispatching
52 /// stops a car is already servicing.
53 pub servicing: Vec<(EntityId, EntityId, f64)>,
54 /// Stops with live demand, returned from `pending_stops_minus_covered`.
55 pub pending_stops: Vec<(EntityId, f64)>,
56 /// Aboard-rider destinations across idle cars — consulted so a
57 /// stop that a car aboard wants to reach stays pickup-eligible.
58 pub idle_rider_destinations: HashSet<EntityId>,
59 /// Per-stop linestamp buffer reused inside `is_covered`.
60 pub lines_here: Vec<EntityId>,
61 /// Pinned hall-call `(car, stop)` pairs for the current group.
62 pub pinned_pairs: Vec<(EntityId, EntityId)>,
63 /// Committed `(car, target)` pairs — mid-flight cars whose trip
64 /// still has demand; held out of the Hungarian idle pool.
65 pub committed_pairs: Vec<(EntityId, EntityId)>,
66 /// Idle elevator pool `(car, position)` for this group.
67 pub idle_elevators: Vec<(EntityId, f64)>,
68}
69
70impl DispatchScratch {
71 /// Clear every buffer without freeing its backing capacity.
72 ///
73 /// `cost_matrix_mx` is re-sized/re-filled lazily in
74 /// `assign_with_scratch`; leaving it alone here preserves its
75 /// capacity when the group's (rows, cols) match the last
76 /// dispatch pass.
77 pub fn clear_all(&mut self) {
78 self.servicing.clear();
79 self.pending_stops.clear();
80 self.idle_rider_destinations.clear();
81 self.lines_here.clear();
82 self.pinned_pairs.clear();
83 self.committed_pairs.clear();
84 self.idle_elevators.clear();
85 }
86}
87
88/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
89/// building the cost matrix for the Hungarian solver. Chosen so that
90/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
91/// sums weights and potentials across each row/column internally, so
92/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
93/// cars or stops before any arithmetic risk appears.
94const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
95/// Fixed-point scale for converting `f64` costs to the `i64` values the
96/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter:
97/// the smallest meaningful rank delta is sub-tick / sub-millimeter, so
98/// scaling by 1e6 keeps that delta as a 1-unit `i64` difference and
99/// preserves the strategy's tie-breaking precision through the cast.
100const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
101
102/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
103/// solver consumes. Non-finite, negative, or overflow-prone inputs map
104/// to the unavailable sentinel.
105fn scale_cost(cost: f64) -> i64 {
106 if !cost.is_finite() || cost < 0.0 {
107 debug_assert!(
108 cost.is_finite() && cost >= 0.0,
109 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
110 );
111 return ASSIGNMENT_SENTINEL;
112 }
113 // Cap at just below sentinel so any real rank always beats unavailable.
114 (cost * ASSIGNMENT_SCALE)
115 .round()
116 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
117}
118
119/// Build the pending-demand stop list, subtracting stops whose
120/// demand is already being absorbed by a car — either currently in
121/// its door cycle at the stop, or en route via `MovingToStop`.
122///
123/// Both phases count as "servicing" because they represent a
124/// commitment to open doors at the target with remaining capacity
125/// that waiting riders can (typically) fit into. Without the
126/// `MovingToStop` case, a new idle car becoming available during
127/// car A's trip to the lobby gets paired with the same lobby call
128/// on the next dispatch tick — car B travels empty behind car A
129/// and the playground shows two cars doing a lobby touch-and-go
130/// for one rider. Composes with the commitment set in
131/// [`systems::dispatch`](crate::systems::dispatch), which excludes
132/// committed cars from the idle pool at the same time.
133///
134/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
135/// the list: that's a legitimately reassignable state.
136/// `Repositioning` is also excluded — a repositioning car doesn't
137/// open doors on arrival, so it cannot absorb waiting riders.
138///
139/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
140/// pending even when a car is present, because a car on Shaft A
141/// can't absorb a rider pinned to Shaft B. Coverage also fails
142/// when the waiting riders' combined weight exceeds the servicing
143/// car's remaining capacity — the leftover spills out when doors
144/// close and deserves its own dispatch immediately.
145fn pending_stops_minus_covered(
146 group: &ElevatorGroup,
147 manifest: &DispatchManifest,
148 world: &World,
149 idle_cars: &[(EntityId, f64)],
150 scratch: &mut DispatchScratch,
151) {
152 // Refill `scratch.servicing` in place — the buffer survives across
153 // ticks so the hot path doesn't reallocate per group.
154 scratch.servicing.clear();
155 for &eid in group.elevator_entities() {
156 let Some(car) = world.elevator(eid) else {
157 continue;
158 };
159 let Some(target) = car.target_stop() else {
160 continue;
161 };
162 if !matches!(
163 car.phase(),
164 ElevatorPhase::MovingToStop(_)
165 | ElevatorPhase::DoorOpening
166 | ElevatorPhase::Loading
167 | ElevatorPhase::DoorClosing
168 ) {
169 continue;
170 }
171 let remaining = car.weight_capacity().value() - car.current_load().value();
172 scratch.servicing.push((target, car.line(), remaining));
173 }
174
175 // Aboard-rider destinations — reused buffer, same owned semantics.
176 scratch.idle_rider_destinations.clear();
177 for &(car_eid, _) in idle_cars {
178 if let Some(car) = world.elevator(car_eid) {
179 for &rid in car.riders() {
180 if let Some(dest) = world.route(rid).and_then(Route::current_destination) {
181 scratch.idle_rider_destinations.insert(dest);
182 }
183 }
184 }
185 }
186
187 // A stop is "covered" iff every waiting rider this group sees can
188 // board at least one of the door-cycling cars here (line check)
189 // AND the combined remaining capacity of the cars whose line
190 // accepts the rider is enough to board them all (capacity check).
191 //
192 // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
193 // so `TransportMode::Walk` riders and cross-group-routed riders
194 // (excluded by `build_manifest`) don't inflate the weight total.
195 // `lines_here` is the same `scratch.lines_here` buffer each call —
196 // cleared then refilled — so coverage checks don't churn the
197 // allocator per stop.
198 let mut lines_here: Vec<EntityId> = std::mem::take(&mut scratch.lines_here);
199 let servicing = &scratch.servicing;
200 let is_covered = |stop_eid: EntityId, lines_here: &mut Vec<EntityId>| -> bool {
201 lines_here.clear();
202 let mut capacity_here = 0.0;
203 for &(stop, line, rem) in servicing {
204 if stop == stop_eid {
205 lines_here.push(line);
206 capacity_here += rem;
207 }
208 }
209 if lines_here.is_empty() {
210 return false;
211 }
212 let mut total_weight = 0.0;
213 for rider in manifest.waiting_riders_at(stop_eid) {
214 let required_line = world
215 .route(rider.id)
216 .and_then(Route::current)
217 .and_then(|leg| match leg.via {
218 TransportMode::Line(l) => Some(l),
219 _ => None,
220 });
221 if let Some(required) = required_line
222 && !lines_here.contains(&required)
223 {
224 return false;
225 }
226 total_weight += rider.weight.value();
227 }
228 total_weight <= capacity_here
229 };
230
231 scratch.pending_stops.clear();
232 for &stop in group.stop_entities() {
233 if !manifest.has_demand(stop) {
234 continue;
235 }
236 let keep =
237 scratch.idle_rider_destinations.contains(&stop) || !is_covered(stop, &mut lines_here);
238 if !keep {
239 continue;
240 }
241 if let Some(pos) = world.stop_position(stop) {
242 scratch.pending_stops.push((stop, pos));
243 }
244 }
245 // Return the lines_here buffer to scratch so its capacity survives.
246 scratch.lines_here = lines_here;
247}
248
249/// Run one group's assignment pass: build the cost matrix, solve the
250/// optimal bipartite matching, then resolve unassigned cars via
251/// [`DispatchStrategy::fallback`].
252///
253/// Visible to the `systems` module; not part of the public API.
254/// Back-compat wrapper that allocates a throw-away scratch for
255/// tests and one-off callers. Production paths (in
256/// `crate::systems::dispatch::run`) must use
257/// [`assign_with_scratch`] so the scratch capacity amortises
258/// across ticks.
259#[cfg(test)]
260pub fn assign(
261 strategy: &mut dyn DispatchStrategy,
262 idle_cars: &[(EntityId, f64)],
263 group: &ElevatorGroup,
264 manifest: &DispatchManifest,
265 world: &World,
266) -> AssignmentResult {
267 let mut scratch = DispatchScratch::default();
268 assign_with_scratch(strategy, idle_cars, group, manifest, world, &mut scratch)
269}
270
271/// Run one group's assignment pass: build the cost matrix, solve the
272/// optimal bipartite matching, then resolve unassigned cars via
273/// [`DispatchStrategy::fallback`]. Uses `scratch` so the per-tick
274/// allocations (cost matrix, pending stops, etc.) reuse capacity
275/// across invocations.
276pub fn assign_with_scratch(
277 strategy: &mut dyn DispatchStrategy,
278 idle_cars: &[(EntityId, f64)],
279 group: &ElevatorGroup,
280 manifest: &DispatchManifest,
281 world: &World,
282 scratch: &mut DispatchScratch,
283) -> AssignmentResult {
284 // Fill `scratch.pending_stops` in place. The buffer's capacity
285 // survives across ticks.
286 pending_stops_minus_covered(group, manifest, world, idle_cars, scratch);
287
288 let n = idle_cars.len();
289 let m = scratch.pending_stops.len();
290
291 if n == 0 {
292 return AssignmentResult {
293 decisions: Vec::new(),
294 };
295 }
296
297 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
298
299 if m == 0 {
300 for &(eid, pos) in idle_cars {
301 let d = strategy.fallback(eid, pos, group, manifest, world);
302 decisions.push((eid, d));
303 }
304 return AssignmentResult { decisions };
305 }
306
307 // Hungarian requires rows <= cols. Reuse the scratch `Matrix` when
308 // the shape matches the previous dispatch pass — on a realistic
309 // building the (rows, cols) tuple changes only when the car or
310 // stop count does, so steady-state dispatch avoids any heap
311 // traffic for the cost matrix at all. When the shape does change,
312 // a fresh Matrix replaces the stored one and becomes the new
313 // reusable buffer going forward.
314 let cols = n.max(m);
315 match &mut scratch.cost_matrix_mx {
316 Some(mx) if mx.rows == n && mx.columns == cols => {
317 mx.fill(ASSIGNMENT_SENTINEL);
318 }
319 slot => {
320 *slot = Some(pathfinding::matrix::Matrix::new(
321 n,
322 cols,
323 ASSIGNMENT_SENTINEL,
324 ));
325 }
326 }
327 let matrix_ref = scratch
328 .cost_matrix_mx
329 .as_mut()
330 .unwrap_or_else(|| unreachable!("cost_matrix_mx populated by match above"));
331
332 {
333 let pending_stops = &scratch.pending_stops;
334 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
335 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
336 // Borrow the car's restricted-stops set for this row so each
337 // (car, stop) pair can short-circuit before calling rank().
338 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
339 // happily ranked restricted pairs and `commit_go_to_stop` later
340 // silently dropped the assignment, starving the call. (#256)
341 let restricted = world
342 .elevator(car_eid)
343 .map(crate::components::Elevator::restricted_stops);
344
345 // The car's line's `serves` list is the set of stops it can
346 // physically reach. In a single-line group every stop is
347 // served (filter is a no-op); in a multi-line group (e.g.
348 // sky-lobby + service bank, low/high banks sharing a
349 // transfer floor) a car on line A must not be assigned to
350 // a stop only line B serves — it would commit, sit there
351 // unable to reach, and starve the call. The pre-fix matrix
352 // happily ranked such cross-line pairs because no other
353 // gate caught them: `restricted_stops` is for explicit
354 // access denials, `pending_stops_minus_covered` filters
355 // stops not cars, and built-in strategies score on
356 // distance/direction without consulting line topology.
357 let car_serves: Option<&[EntityId]> = world
358 .elevator(car_eid)
359 .map(crate::components::Elevator::line)
360 .and_then(|line_eid| {
361 group
362 .lines()
363 .iter()
364 .find(|li| li.entity() == line_eid)
365 .map(LineInfo::serves)
366 });
367 // `None` here means the car's line isn't in this group's
368 // line list — a topology inconsistency that should be
369 // unreachable. We can't fail the dispatch tick over it (the
370 // sim still has to make progress), so the filter falls
371 // open: the car is treated as if it could reach any stop.
372 // The debug-assert catches it during testing without
373 // affecting release builds.
374 debug_assert!(
375 world.elevator(car_eid).is_none() || car_serves.is_some(),
376 "car {car_eid:?} on line not present in its group's lines list"
377 );
378
379 for (j, &(stop_eid, _stop_pos)) in pending_stops.iter().enumerate() {
380 if restricted.is_some_and(|r| r.contains(&stop_eid)) {
381 continue; // leave SENTINEL — this pair is unavailable
382 }
383 if car_serves.is_some_and(|s| !s.contains(&stop_eid)) {
384 continue; // car's line doesn't reach this stop
385 }
386 let ctx = RankContext {
387 car: car_eid,
388 stop: stop_eid,
389 group,
390 manifest,
391 world,
392 };
393 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
394 matrix_ref[(i, j)] = scaled;
395 }
396 }
397 }
398 let matrix = &*matrix_ref;
399 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(matrix);
400
401 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
402 let col = assignments[i];
403 // A real assignment is: col points to a real stop (col < m) AND
404 // the cost isn't sentinel-padded (meaning rank() returned Some).
405 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
406 let (stop_eid, _) = scratch.pending_stops[col];
407 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
408 } else {
409 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
410 decisions.push((car_eid, d));
411 }
412 }
413
414 AssignmentResult { decisions }
415}