Skip to main content

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}