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 /// Per-car `(distance, pending_stops_index)` buffer used by the
69 /// top-K candidate-pruning pass. Cleared and refilled per row in
70 /// the matrix-fill loop; capacity carries across cars and ticks.
71 pub top_k_buf: Vec<(f64, usize)>,
72}
73
74impl DispatchScratch {
75 /// Clear every buffer without freeing its backing capacity.
76 ///
77 /// `cost_matrix_mx` is re-sized/re-filled lazily in
78 /// `assign_with_scratch`; leaving it alone here preserves its
79 /// capacity when the group's (rows, cols) match the last
80 /// dispatch pass. `top_k_buf` is cleared per-row inside the
81 /// matrix-fill loop, not here.
82 pub fn clear_all(&mut self) {
83 self.servicing.clear();
84 self.pending_stops.clear();
85 self.idle_rider_destinations.clear();
86 self.lines_here.clear();
87 self.pinned_pairs.clear();
88 self.committed_pairs.clear();
89 self.idle_elevators.clear();
90 }
91}
92
93/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
94/// building the cost matrix for the Hungarian solver. Chosen so that
95/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
96/// sums weights and potentials across each row/column internally, so
97/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
98/// cars or stops before any arithmetic risk appears.
99const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
100/// Fixed-point scale for converting `f64` costs to the `i64` values the
101/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter:
102/// the smallest meaningful rank delta is sub-tick / sub-millimeter, so
103/// scaling by 1e6 keeps that delta as a 1-unit `i64` difference and
104/// preserves the strategy's tie-breaking precision through the cast.
105const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
106
107/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
108/// solver consumes. Non-finite, negative, or overflow-prone inputs map
109/// to the unavailable sentinel.
110fn scale_cost(cost: f64) -> i64 {
111 if !cost.is_finite() || cost < 0.0 {
112 debug_assert!(
113 cost.is_finite() && cost >= 0.0,
114 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
115 );
116 return ASSIGNMENT_SENTINEL;
117 }
118 // Cap at just below sentinel so any real rank always beats unavailable.
119 (cost * ASSIGNMENT_SCALE)
120 .round()
121 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
122}
123
124/// Build the pending-demand stop list, subtracting stops whose
125/// demand is already being absorbed by a car — either currently in
126/// its door cycle at the stop, or en route via `MovingToStop`.
127///
128/// Both phases count as "servicing" because they represent a
129/// commitment to open doors at the target with remaining capacity
130/// that waiting riders can (typically) fit into. Without the
131/// `MovingToStop` case, a new idle car becoming available during
132/// car A's trip to the lobby gets paired with the same lobby call
133/// on the next dispatch tick — car B travels empty behind car A
134/// and the playground shows two cars doing a lobby touch-and-go
135/// for one rider. Composes with the commitment set in
136/// [`systems::dispatch`](crate::systems::dispatch), which excludes
137/// committed cars from the idle pool at the same time.
138///
139/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
140/// the list: that's a legitimately reassignable state.
141/// `Repositioning` is also excluded — a repositioning car doesn't
142/// open doors on arrival, so it cannot absorb waiting riders.
143///
144/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
145/// pending even when a car is present, because a car on Shaft A
146/// can't absorb a rider pinned to Shaft B. Coverage also fails
147/// when the waiting riders' combined weight exceeds the servicing
148/// car's remaining capacity — the leftover spills out when doors
149/// close and deserves its own dispatch immediately.
150fn pending_stops_minus_covered(
151 group: &ElevatorGroup,
152 manifest: &DispatchManifest,
153 world: &World,
154 idle_cars: &[(EntityId, f64)],
155 scratch: &mut DispatchScratch,
156) {
157 // Refill `scratch.servicing` in place — the buffer survives across
158 // ticks so the hot path doesn't reallocate per group.
159 scratch.servicing.clear();
160 for &eid in group.elevator_entities() {
161 let Some(car) = world.elevator(eid) else {
162 continue;
163 };
164 let Some(target) = car.target_stop() else {
165 continue;
166 };
167 if !matches!(
168 car.phase(),
169 ElevatorPhase::MovingToStop(_)
170 | ElevatorPhase::DoorOpening
171 | ElevatorPhase::Loading
172 | ElevatorPhase::DoorClosing
173 ) {
174 continue;
175 }
176 let remaining = car.weight_capacity().value() - car.current_load().value();
177 scratch.servicing.push((target, car.line(), remaining));
178 }
179
180 // Aboard-rider destinations — reused buffer, same owned semantics.
181 scratch.idle_rider_destinations.clear();
182 for &(car_eid, _) in idle_cars {
183 if let Some(car) = world.elevator(car_eid) {
184 for &rid in car.riders() {
185 if let Some(dest) = world.route(rid).and_then(Route::current_destination) {
186 scratch.idle_rider_destinations.insert(dest);
187 }
188 }
189 }
190 }
191
192 // A stop is "covered" iff every waiting rider this group sees can
193 // board at least one of the door-cycling cars here (line check)
194 // AND the combined remaining capacity of the cars whose line
195 // accepts the rider is enough to board them all (capacity check).
196 //
197 // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
198 // so `TransportMode::Walk` riders and cross-group-routed riders
199 // (excluded by `build_manifest`) don't inflate the weight total.
200 // `lines_here` is the same `scratch.lines_here` buffer each call —
201 // cleared then refilled — so coverage checks don't churn the
202 // allocator per stop.
203 let mut lines_here: Vec<EntityId> = std::mem::take(&mut scratch.lines_here);
204 let servicing = &scratch.servicing;
205 let is_covered = |stop_eid: EntityId, lines_here: &mut Vec<EntityId>| -> bool {
206 lines_here.clear();
207 let mut capacity_here = 0.0;
208 for &(stop, line, rem) in servicing {
209 if stop == stop_eid {
210 lines_here.push(line);
211 capacity_here += rem;
212 }
213 }
214 if lines_here.is_empty() {
215 return false;
216 }
217 let mut total_weight = 0.0;
218 for rider in manifest.waiting_riders_at(stop_eid) {
219 let required_line = world
220 .route(rider.id)
221 .and_then(Route::current)
222 .and_then(|leg| match leg.via {
223 TransportMode::Line(l) => Some(l),
224 _ => None,
225 });
226 if let Some(required) = required_line
227 && !lines_here.contains(&required)
228 {
229 return false;
230 }
231 total_weight += rider.weight.value();
232 }
233 total_weight <= capacity_here
234 };
235
236 scratch.pending_stops.clear();
237 for &stop in group.stop_entities() {
238 if !manifest.has_demand(stop) {
239 continue;
240 }
241 let keep =
242 scratch.idle_rider_destinations.contains(&stop) || !is_covered(stop, &mut lines_here);
243 if !keep {
244 continue;
245 }
246 if let Some(pos) = world.stop_position(stop) {
247 scratch.pending_stops.push((stop, pos));
248 }
249 }
250 // Return the lines_here buffer to scratch so its capacity survives.
251 scratch.lines_here = lines_here;
252}
253
254/// Run one group's assignment pass: build the cost matrix, solve the
255/// optimal bipartite matching, then resolve unassigned cars via
256/// [`DispatchStrategy::fallback`].
257///
258/// Visible to the `systems` module; not part of the public API.
259/// Back-compat wrapper that allocates a throw-away scratch for
260/// tests and one-off callers. Production paths (in
261/// `crate::systems::dispatch::run`) must use
262/// [`assign_with_scratch`] so the scratch capacity amortises
263/// across ticks.
264#[cfg(test)]
265pub fn assign(
266 strategy: &mut dyn DispatchStrategy,
267 idle_cars: &[(EntityId, f64)],
268 group: &ElevatorGroup,
269 manifest: &DispatchManifest,
270 world: &World,
271) -> AssignmentResult {
272 let mut scratch = DispatchScratch::default();
273 assign_with_scratch(strategy, idle_cars, group, manifest, world, &mut scratch)
274}
275
276/// Run one group's assignment pass: build the cost matrix, solve the
277/// optimal bipartite matching, then resolve unassigned cars via
278/// [`DispatchStrategy::fallback`]. Uses `scratch` so the per-tick
279/// allocations (cost matrix, pending stops, etc.) reuse capacity
280/// across invocations.
281#[allow(clippy::too_many_lines)]
282pub fn assign_with_scratch(
283 strategy: &mut dyn DispatchStrategy,
284 idle_cars: &[(EntityId, f64)],
285 group: &ElevatorGroup,
286 manifest: &DispatchManifest,
287 world: &World,
288 scratch: &mut DispatchScratch,
289) -> AssignmentResult {
290 // Fill `scratch.pending_stops` in place. The buffer's capacity
291 // survives across ticks.
292 pending_stops_minus_covered(group, manifest, world, idle_cars, scratch);
293
294 let n = idle_cars.len();
295 let m = scratch.pending_stops.len();
296
297 if n == 0 {
298 return AssignmentResult {
299 decisions: Vec::new(),
300 };
301 }
302
303 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
304
305 if m == 0 {
306 for &(eid, pos) in idle_cars {
307 let d = strategy.fallback(eid, pos, group, manifest, world);
308 decisions.push((eid, d));
309 }
310 return AssignmentResult { decisions };
311 }
312
313 // Hungarian requires rows <= cols. Reuse the scratch `Matrix` when
314 // the shape matches the previous dispatch pass — on a realistic
315 // building the (rows, cols) tuple changes only when the car or
316 // stop count does, so steady-state dispatch avoids any heap
317 // traffic for the cost matrix at all. When the shape does change,
318 // a fresh Matrix replaces the stored one and becomes the new
319 // reusable buffer going forward.
320 let cols = n.max(m);
321 match &mut scratch.cost_matrix_mx {
322 Some(mx) if mx.rows == n && mx.columns == cols => {
323 mx.fill(ASSIGNMENT_SENTINEL);
324 }
325 slot => {
326 *slot = Some(pathfinding::matrix::Matrix::new(
327 n,
328 cols,
329 ASSIGNMENT_SENTINEL,
330 ));
331 }
332 }
333 let matrix_ref = scratch
334 .cost_matrix_mx
335 .as_mut()
336 .unwrap_or_else(|| unreachable!("cost_matrix_mx populated by match above"));
337
338 // Top-K candidate pruning: when the strategy returns `Some(K)`,
339 // each car only scores its K nearest viable pending stops; the
340 // rest stay sentinel-cost so the Hungarian skips them. Cuts
341 // per-cell rank() calls dramatically at large m without changing
342 // the matrix shape (pathfinding's row/column reduction handles
343 // the sparse rows efficiently).
344 //
345 // Determinism: tie-break on (distance, EntityId) so the kept set
346 // is the same across runs and across snapshot round-trip.
347 let candidate_limit = strategy.candidate_limit();
348 // Take the buffer out of scratch so we can borrow `pending_stops`
349 // and the top-K buf simultaneously without aliasing the same
350 // `&mut scratch`.
351 let mut top_k_buf = std::mem::take(&mut scratch.top_k_buf);
352 {
353 let pending_stops = &scratch.pending_stops;
354 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
355 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
356 // Borrow the car's restricted-stops set for this row so each
357 // (car, stop) pair can short-circuit before calling rank().
358 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
359 // happily ranked restricted pairs and `commit_go_to_stop` later
360 // silently dropped the assignment, starving the call. (#256)
361 let restricted = world
362 .elevator(car_eid)
363 .map(crate::components::Elevator::restricted_stops);
364
365 // The car's line's `serves` list is the set of stops it can
366 // physically reach. In a single-line group every stop is
367 // served (filter is a no-op); in a multi-line group (e.g.
368 // sky-lobby + service bank, low/high banks sharing a
369 // transfer floor) a car on line A must not be assigned to
370 // a stop only line B serves — it would commit, sit there
371 // unable to reach, and starve the call. The pre-fix matrix
372 // happily ranked such cross-line pairs because no other
373 // gate caught them: `restricted_stops` is for explicit
374 // access denials, `pending_stops_minus_covered` filters
375 // stops not cars, and built-in strategies score on
376 // distance/direction without consulting line topology.
377 let car_serves: Option<&[EntityId]> = world
378 .elevator(car_eid)
379 .map(crate::components::Elevator::line)
380 .and_then(|line_eid| {
381 group
382 .lines()
383 .iter()
384 .find(|li| li.entity() == line_eid)
385 .map(LineInfo::serves)
386 });
387 // `None` here means the car's line isn't in this group's
388 // line list — a topology inconsistency that should be
389 // unreachable. We can't fail the dispatch tick over it (the
390 // sim still has to make progress), so the filter falls
391 // open: the car is treated as if it could reach any stop.
392 // The debug-assert catches it during testing without
393 // affecting release builds.
394 debug_assert!(
395 world.elevator(car_eid).is_none() || car_serves.is_some(),
396 "car {car_eid:?} on line not present in its group's lines list"
397 );
398
399 // Build (distance, pending_stops index) for every VIABLE
400 // candidate. Line- and restricted-filter happen here so
401 // the top-K cut applies to viable candidates only — a
402 // line-restricted car still sees up to K reachable stops.
403 top_k_buf.clear();
404 for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
405 if restricted.is_some_and(|r| r.contains(&stop_eid)) {
406 continue;
407 }
408 if car_serves.is_some_and(|s| !s.contains(&stop_eid)) {
409 continue;
410 }
411 let dist = (car_pos - stop_pos).abs();
412 top_k_buf.push((dist, j));
413 }
414
415 // Apply the top-K cut. `select_nth_unstable_by` partitions
416 // in O(m) so the K smallest entries land in [..k]; the
417 // matrix-fill loop below indexes by `j` and ignores the
418 // within-set order, so a full O(m log m) sort would burn
419 // cycles right where pruning is meant to save them. The
420 // comparator is total (distance + EntityId tie-break with
421 // no equal pairs across distinct stops), so the kept set
422 // is deterministic across runs and snapshot round-trip.
423 if let Some(k) = candidate_limit
424 && top_k_buf.len() > k
425 {
426 debug_assert!(
427 k > 0,
428 "candidate_limit Some(0) silently sentinels every cell — \
429 use None to disable pruning instead"
430 );
431 top_k_buf.select_nth_unstable_by(k - 1, |&(da, ja), &(db, jb)| {
432 da.partial_cmp(&db)
433 .unwrap_or(std::cmp::Ordering::Equal)
434 .then_with(|| pending_stops[ja].0.cmp(&pending_stops[jb].0))
435 });
436 top_k_buf.truncate(k);
437 }
438
439 // Fill the matrix only for kept indices. Non-viable and
440 // non-top-K cells stay at the SENTINEL value the matrix
441 // was initialised with.
442 for &(_, j) in &top_k_buf {
443 let (stop_eid, _) = pending_stops[j];
444 let ctx = RankContext {
445 car: car_eid,
446 stop: stop_eid,
447 group,
448 manifest,
449 world,
450 };
451 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
452 matrix_ref[(i, j)] = scaled;
453 }
454 }
455 }
456 // Return the buffer to scratch so its capacity carries to the next
457 // group/tick.
458 scratch.top_k_buf = top_k_buf;
459 let matrix = &*matrix_ref;
460 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(matrix);
461
462 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
463 let col = assignments[i];
464 // A real assignment is: col points to a real stop (col < m) AND
465 // the cost isn't sentinel-padded (meaning rank() returned Some).
466 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
467 let (stop_eid, _) = scratch.pending_stops[col];
468 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
469 } else {
470 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
471 decisions.push((car_eid, d));
472 }
473 }
474
475 AssignmentResult { decisions }
476}