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}
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}