elevator_core/dispatch/mod.rs
1//! Pluggable dispatch strategies for assigning elevators to stops.
2//!
3//! Strategies express preferences as scores on `(car, stop)` pairs via
4//! [`DispatchStrategy::rank`](crate::dispatch::DispatchStrategy::rank). The
5//! dispatch system then runs an optimal bipartite assignment (Kuhn–Munkres /
6//! Hungarian algorithm) so coordination — one car per hall call — is a library
7//! invariant, not a per-strategy responsibility. Cars left unassigned are
8//! handed to [`DispatchStrategy::fallback`](crate::dispatch::DispatchStrategy::fallback)
9//! for per-car policy (idle, park, etc.).
10//!
11//! # Example: custom dispatch strategy
12//!
13//! ```rust
14//! use elevator_core::dispatch::RankContext;
15//! use elevator_core::prelude::*;
16//!
17//! struct AlwaysFirstStop;
18//!
19//! impl DispatchStrategy for AlwaysFirstStop {
20//! fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
21//! // Prefer the group's first stop; everything else is unavailable.
22//! if Some(&ctx.stop) == ctx.group.stop_entities().first() {
23//! Some((ctx.car_position - ctx.stop_position).abs())
24//! } else {
25//! None
26//! }
27//! }
28//! }
29//!
30//! let sim = SimulationBuilder::demo()
31//! .dispatch(AlwaysFirstStop)
32//! .build()
33//! .unwrap();
34//! ```
35
36/// Hall-call destination dispatch algorithm.
37pub mod destination;
38/// Estimated Time to Destination dispatch algorithm.
39pub mod etd;
40/// LOOK dispatch algorithm.
41pub mod look;
42/// Nearest-car dispatch algorithm.
43pub mod nearest_car;
44/// Built-in repositioning strategies.
45pub mod reposition;
46/// Relative System Response (RSR) dispatch algorithm.
47pub mod rsr;
48/// SCAN dispatch algorithm.
49pub mod scan;
50/// Shared sweep-direction logic used by SCAN and LOOK.
51pub(crate) mod sweep;
52
53pub use destination::{AssignedCar, DestinationDispatch};
54pub use etd::EtdDispatch;
55pub use look::LookDispatch;
56pub use nearest_car::NearestCarDispatch;
57pub use rsr::RsrDispatch;
58pub use scan::ScanDispatch;
59
60use serde::{Deserialize, Serialize};
61
62use crate::components::{
63 CallDirection, CarCall, ElevatorPhase, HallCall, Route, TransportMode, Weight,
64};
65use crate::entity::EntityId;
66use crate::ids::GroupId;
67use crate::world::World;
68use std::collections::{BTreeMap, HashSet};
69
70/// Whether assigning `ctx.car` to `ctx.stop` is worth ranking.
71///
72/// Combines two checks every dispatch strategy needs at the top of its
73/// `rank` implementation:
74///
75/// 1. **Servability** — capacity, full-load bypass, and the loading-phase
76/// boarding filter. A pair that can't exit an aboard rider, board a
77/// waiter, or answer a rider-less hall call is a no-op move (and a
78/// zero-cost one when the car is already parked there) which would
79/// otherwise stall doors against unservable demand.
80/// 2. **Path discipline** (only when `respect_aboard_path` is `true`) —
81/// refuses pickups that would pull a car carrying routed riders off
82/// the direct path to every aboard rider's destination. Without it, a
83/// stream of closer-destination hall calls can indefinitely preempt a
84/// farther aboard rider's delivery (the "never reaches the
85/// passenger's desired stop" loop).
86///
87/// Strategies with their own direction discipline (SCAN, LOOK, ETD,
88/// Destination) pass `respect_aboard_path: false` because their
89/// sweep/direction terms already rule out backtracks. Strategies without
90/// it (`NearestCar`, RSR) pass `respect_aboard_path: true`. Custom
91/// strategies should pass `true` unless they enforce direction
92/// discipline themselves.
93///
94/// Aboard riders without a published route (game-managed manual riders)
95/// don't constrain the path — any pickup is trivially on-the-way for
96/// them, so the path check trivially passes when no aboard rider has a
97/// `Route::current_destination`.
98#[must_use]
99pub fn pair_is_useful(ctx: &RankContext<'_>, respect_aboard_path: bool) -> bool {
100 let Some(car) = ctx.world.elevator(ctx.car) else {
101 return false;
102 };
103 let can_exit_here = car
104 .riders()
105 .iter()
106 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
107 if can_exit_here {
108 return true;
109 }
110
111 // Direction-dependent full-load bypass (Otis Elevonic 411 model,
112 // patent US5490580A). A car loaded above its configured threshold
113 // in the current travel direction ignores hall calls in that same
114 // direction. Aboard riders still get delivered — the `can_exit_here`
115 // short-circuit above guarantees their destinations remain rank-able.
116 if bypass_in_current_direction(car, ctx) {
117 return false;
118 }
119
120 let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
121 if remaining_capacity <= 0.0 {
122 return false;
123 }
124 let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
125 let servable = if waiting.is_empty() {
126 // No waiters at the stop, and no aboard rider of ours exits here
127 // (the `can_exit_here` short-circuit ruled that out above).
128 // Demand must therefore come from either another car's
129 // `riding_to_stop` (not work this car can perform) or a
130 // rider-less hall call (someone pressed a button with no rider
131 // attached yet — a press from `press_hall_button` or one whose
132 // riders have since been fulfilled or abandoned). Only the
133 // latter is actionable; without this filter an idle car parked
134 // at the stop collapses to cost 0, the Hungarian picks the
135 // self-pair every tick, and doors cycle open/close indefinitely
136 // while the other car finishes its trip.
137 ctx.manifest
138 .hall_calls_at_stop
139 .get(&ctx.stop)
140 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
141 } else {
142 waiting
143 .iter()
144 .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
145 };
146 if !servable {
147 return false;
148 }
149 if !respect_aboard_path || car.riders().is_empty() {
150 return true;
151 }
152
153 // Route-less aboard riders (game-managed manual riders) don't
154 // publish a destination, so there's no committed path to protect.
155 // Any pickup is trivially on-the-way — let it through. Otherwise
156 // we'd refuse every pickup the moment the car carried its first
157 // manually-managed passenger.
158 let has_routed_rider = car.riders().iter().any(|&rid| {
159 ctx.world
160 .route(rid)
161 .and_then(Route::current_destination)
162 .is_some()
163 });
164 if !has_routed_rider {
165 return true;
166 }
167
168 // Pickups allowed only on the path to an aboard rider's destination.
169 // Candidate at the car's position (to_cand = 0) trivially qualifies —
170 // useful for same-floor boards.
171 let to_cand = ctx.stop_position - ctx.car_position;
172 car.riders().iter().any(|&rid| {
173 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
174 return false;
175 };
176 let Some(dest_pos) = ctx.world.stop_position(dest) else {
177 return false;
178 };
179 let to_dest = dest_pos - ctx.car_position;
180 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
181 })
182}
183
184/// Whether a waiting rider could actually board this car, matching the
185/// same filters the loading phase applies. Prevents `pair_is_useful`
186/// from approving a pickup whose only demand is direction-filtered or
187/// over-capacity — the loading phase would reject the rider, doors
188/// would cycle, and dispatch would re-pick the zero-cost self-pair.
189fn rider_can_board(
190 rider: &RiderInfo,
191 car: &crate::components::Elevator,
192 ctx: &RankContext<'_>,
193 remaining_capacity: f64,
194) -> bool {
195 if rider.weight.value() > remaining_capacity {
196 return false;
197 }
198 // Match `systems::loading`'s direction filter: a rider whose trip
199 // goes the opposite way of the car's committed direction will not
200 // be boarded. An unknown destination (no route yet) is treated as
201 // unconstrained — let the rider through and let the loading phase
202 // make the final call.
203 let Some(dest) = rider.destination else {
204 return true;
205 };
206 let Some(dest_pos) = ctx.world.stop_position(dest) else {
207 return true;
208 };
209 if dest_pos > ctx.stop_position && !car.going_up() {
210 return false;
211 }
212 if dest_pos < ctx.stop_position && !car.going_down() {
213 return false;
214 }
215 true
216}
217
218/// True when a full-load bypass applies: the car has a configured
219/// threshold for its current travel direction, is above that threshold,
220/// and the candidate stop lies in that same direction.
221fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
222 // Derive travel direction from the car's current target, if any.
223 // An Idle or Stopped car has no committed direction → no bypass.
224 let Some(target) = car.phase().moving_target() else {
225 return false;
226 };
227 let Some(target_pos) = ctx.world.stop_position(target) else {
228 return false;
229 };
230 let going_up = target_pos > ctx.car_position;
231 let going_down = target_pos < ctx.car_position;
232 if !going_up && !going_down {
233 return false;
234 }
235 let threshold = if going_up {
236 car.bypass_load_up_pct()
237 } else {
238 car.bypass_load_down_pct()
239 };
240 let Some(pct) = threshold else {
241 return false;
242 };
243 let capacity = car.weight_capacity().value();
244 if capacity <= 0.0 {
245 return false;
246 }
247 let load_ratio = car.current_load().value() / capacity;
248 if load_ratio < pct {
249 return false;
250 }
251 // Only same-direction pickups get bypassed.
252 let stop_above = ctx.stop_position > ctx.car_position;
253 let stop_below = ctx.stop_position < ctx.car_position;
254 (going_up && stop_above) || (going_down && stop_below)
255}
256
257/// Metadata about a single rider, available to dispatch strategies.
258#[derive(Debug, Clone)]
259#[non_exhaustive]
260pub struct RiderInfo {
261 /// Rider entity ID.
262 pub id: EntityId,
263 /// Rider's destination stop entity (from route).
264 pub destination: Option<EntityId>,
265 /// Rider weight.
266 pub weight: Weight,
267 /// Ticks this rider has been waiting (0 if riding).
268 pub wait_ticks: u64,
269}
270
271/// Full demand picture for dispatch decisions.
272///
273/// Contains per-rider metadata grouped by stop, enabling entity-aware
274/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
275///
276/// Uses `BTreeMap` for deterministic iteration order.
277#[derive(Debug, Clone, Default)]
278pub struct DispatchManifest {
279 /// Riders waiting at each stop, with full per-rider metadata.
280 pub(crate) waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
281 /// Riders currently aboard elevators, grouped by their destination stop.
282 pub(crate) riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
283 /// Number of residents at each stop (read-only hint for dispatch strategies).
284 pub(crate) resident_count_at_stop: BTreeMap<EntityId, usize>,
285 /// Pending hall calls at each stop — at most two entries per stop
286 /// (one per [`CallDirection`]). Populated only for stops served by
287 /// the group being dispatched. Strategies read this to rank based on
288 /// call age, pending-rider count, pin flags, or DCS destinations.
289 pub(crate) hall_calls_at_stop: BTreeMap<EntityId, Vec<HallCall>>,
290 /// Floor buttons pressed inside each car in the group. Keyed by car
291 /// entity. Strategies read this to plan intermediate stops without
292 /// poking into `World` directly.
293 pub(crate) car_calls_by_car: BTreeMap<EntityId, Vec<CarCall>>,
294 /// Recent arrivals per stop, counted over
295 /// [`DispatchManifest::arrival_window_ticks`] ticks. Populated from
296 /// the [`crate::arrival_log::ArrivalLog`] world resource each pass
297 /// so strategies can read a traffic-rate signal without touching
298 /// world state directly.
299 pub(crate) arrivals_at_stop: BTreeMap<EntityId, u64>,
300 /// Window the `arrivals_at_stop` counts cover, in ticks. Exposed so
301 /// strategies interpreting the raw counts can convert them to a
302 /// rate (per tick or per second).
303 pub(crate) arrival_window_ticks: u64,
304}
305
306impl DispatchManifest {
307 /// Number of riders waiting at a stop.
308 #[must_use]
309 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
310 self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
311 }
312
313 /// Total weight of riders waiting at a stop.
314 #[must_use]
315 pub fn total_weight_at(&self, stop: EntityId) -> f64 {
316 self.waiting_at_stop
317 .get(&stop)
318 .map_or(0.0, |riders| riders.iter().map(|r| r.weight.value()).sum())
319 }
320
321 /// Number of riders heading to a stop (aboard elevators).
322 #[must_use]
323 pub fn riding_count_to(&self, stop: EntityId) -> usize {
324 self.riding_to_stop.get(&stop).map_or(0, Vec::len)
325 }
326
327 /// Whether a stop has any demand for this group: waiting riders,
328 /// riders heading there, or a *rider-less* hall call (one that
329 /// `press_hall_button` placed without a backing rider). Pre-fix
330 /// the rider-less case was invisible to every built-in dispatcher,
331 /// so explicit button presses with no associated rider went
332 /// unanswered indefinitely (#255).
333 ///
334 /// Hall calls *with* `pending_riders` are not double-counted —
335 /// those riders already appear in `waiting_count_at` for the
336 /// groups whose dispatch surface they belong to. Adding the call
337 /// to `has_demand` for *every* group that serves the stop would
338 /// pull cars from groups the rider doesn't even want, causing
339 /// open/close oscillation regression that the multi-group test
340 /// `dispatch_ignores_waiting_rider_targeting_another_group` pins.
341 #[must_use]
342 pub fn has_demand(&self, stop: EntityId) -> bool {
343 self.waiting_count_at(stop) > 0
344 || self.riding_count_to(stop) > 0
345 || self
346 .hall_calls_at_stop
347 .get(&stop)
348 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
349 }
350
351 /// Number of residents at a stop (read-only hint, not active demand).
352 #[must_use]
353 pub fn resident_count_at(&self, stop: EntityId) -> usize {
354 self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
355 }
356
357 /// Rider arrivals at `stop` within the last
358 /// [`arrival_window_ticks`](Self::arrival_window_ticks) ticks. The
359 /// signal is the rolling-window per-stop arrival rate that
360 /// commercial controllers use to pick a traffic mode and that
361 /// [`crate::dispatch::reposition::PredictiveParking`] uses to
362 /// forecast demand. Unvisited stops return 0.
363 #[must_use]
364 pub fn arrivals_at(&self, stop: EntityId) -> u64 {
365 self.arrivals_at_stop.get(&stop).copied().unwrap_or(0)
366 }
367
368 /// Window size (in ticks) over which [`arrivals_at`](Self::arrivals_at)
369 /// counts events. Strategies convert counts to rates by dividing
370 /// by this.
371 #[must_use]
372 pub const fn arrival_window_ticks(&self) -> u64 {
373 self.arrival_window_ticks
374 }
375
376 /// The hall call at `(stop, direction)`, if pressed.
377 #[must_use]
378 pub fn hall_call_at(&self, stop: EntityId, direction: CallDirection) -> Option<&HallCall> {
379 self.hall_calls_at_stop
380 .get(&stop)?
381 .iter()
382 .find(|c| c.direction == direction)
383 }
384
385 /// All hall calls across every stop in the group (flattened iterator).
386 ///
387 /// No `#[must_use]` needed: `impl Iterator` already carries that
388 /// annotation, and adding our own triggers clippy's
389 /// `double_must_use` lint.
390 pub fn iter_hall_calls(&self) -> impl Iterator<Item = &HallCall> {
391 self.hall_calls_at_stop.values().flatten()
392 }
393
394 /// Floor buttons currently pressed inside `car`. Empty slice if the
395 /// car has no aboard riders or no outstanding presses.
396 #[must_use]
397 pub fn car_calls_for(&self, car: EntityId) -> &[CarCall] {
398 self.car_calls_by_car.get(&car).map_or(&[], Vec::as_slice)
399 }
400
401 /// Riders waiting at a specific stop.
402 #[must_use]
403 pub fn waiting_riders_at(&self, stop: EntityId) -> &[RiderInfo] {
404 self.waiting_at_stop.get(&stop).map_or(&[], Vec::as_slice)
405 }
406
407 /// Iterate over all `(stop, riders)` pairs with waiting demand.
408 pub fn iter_waiting_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
409 self.waiting_at_stop
410 .iter()
411 .map(|(stop, riders)| (stop, riders.as_slice()))
412 }
413
414 /// Riders currently riding toward a specific stop.
415 #[must_use]
416 pub fn riding_riders_to(&self, stop: EntityId) -> &[RiderInfo] {
417 self.riding_to_stop.get(&stop).map_or(&[], Vec::as_slice)
418 }
419
420 /// Iterate over all `(stop, riders)` pairs with in-transit demand.
421 pub fn iter_riding_stops(&self) -> impl Iterator<Item = (&EntityId, &[RiderInfo])> {
422 self.riding_to_stop
423 .iter()
424 .map(|(stop, riders)| (stop, riders.as_slice()))
425 }
426
427 /// Iterate over all `(stop, hall_calls)` pairs with active calls.
428 pub fn iter_hall_call_stops(&self) -> impl Iterator<Item = (&EntityId, &[HallCall])> {
429 self.hall_calls_at_stop
430 .iter()
431 .map(|(stop, calls)| (stop, calls.as_slice()))
432 }
433}
434
435/// Serializable identifier for built-in dispatch strategies.
436///
437/// Used in snapshots and config files to restore the correct strategy
438/// without requiring the game to manually re-wire dispatch. Custom strategies
439/// are represented by the `Custom(String)` variant.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum BuiltinStrategy {
443 /// SCAN (elevator) algorithm — sweeps end-to-end.
444 Scan,
445 /// LOOK algorithm — reverses at last request.
446 Look,
447 /// Nearest-car — assigns closest idle elevator.
448 NearestCar,
449 /// Estimated Time to Destination — minimizes total cost.
450 Etd,
451 /// Hall-call destination dispatch — sticky per-rider assignment.
452 Destination,
453 /// Relative System Response — additive composite of ETA, direction,
454 /// car-call affinity, and load-share terms.
455 Rsr,
456 /// Custom strategy identified by name. The game must provide a factory.
457 Custom(String),
458}
459
460impl std::fmt::Display for BuiltinStrategy {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 match self {
463 Self::Scan => write!(f, "Scan"),
464 Self::Look => write!(f, "Look"),
465 Self::NearestCar => write!(f, "NearestCar"),
466 Self::Etd => write!(f, "Etd"),
467 Self::Destination => write!(f, "Destination"),
468 Self::Rsr => write!(f, "Rsr"),
469 Self::Custom(name) => write!(f, "Custom({name})"),
470 }
471 }
472}
473
474impl BuiltinStrategy {
475 /// Instantiate the dispatch strategy for this variant.
476 ///
477 /// Returns `None` for `Custom` — the game must provide those via
478 /// a factory function.
479 #[must_use]
480 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
481 match self {
482 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
483 Self::Look => Some(Box::new(look::LookDispatch::new())),
484 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
485 // `Default` ships the tuned stack (age-linear fairness term
486 // active); `new()` is the zero baseline for mutant/unit
487 // tests that isolate single terms. The playground's "ETD"
488 // dropdown entry should map to the strategy with fairness
489 // protection, not the raw version that lets the max-wait
490 // tail drift unbounded.
491 Self::Etd => Some(Box::new(etd::EtdDispatch::default())),
492 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
493 // `Default` ships with the tuned penalty stack; `new()` is
494 // the zero baseline for additive-composition tests. The
495 // playground's "RSR" dropdown entry should map to the
496 // actual strategy, not to NearestCar-in-disguise, so use
497 // `Default` here.
498 Self::Rsr => Some(Box::new(rsr::RsrDispatch::default())),
499 Self::Custom(_) => None,
500 }
501 }
502}
503
504/// Decision returned by a dispatch strategy.
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[non_exhaustive]
507pub enum DispatchDecision {
508 /// Go to the specified stop entity.
509 GoToStop(EntityId),
510 /// Remain idle.
511 Idle,
512}
513
514/// Per-line relationship data within an [`ElevatorGroup`].
515///
516/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
517/// The source of truth for intrinsic line properties is the
518/// [`Line`](crate::components::Line) component in World.
519#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct LineInfo {
521 /// Line entity ID.
522 entity: EntityId,
523 /// Elevator entities on this line.
524 elevators: Vec<EntityId>,
525 /// Stop entities served by this line.
526 serves: Vec<EntityId>,
527}
528
529impl LineInfo {
530 /// Create a new `LineInfo`.
531 #[must_use]
532 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
533 Self {
534 entity,
535 elevators,
536 serves,
537 }
538 }
539
540 /// Line entity ID.
541 #[must_use]
542 pub const fn entity(&self) -> EntityId {
543 self.entity
544 }
545
546 /// Elevator entities on this line.
547 #[must_use]
548 pub fn elevators(&self) -> &[EntityId] {
549 &self.elevators
550 }
551
552 /// Stop entities served by this line.
553 #[must_use]
554 pub fn serves(&self) -> &[EntityId] {
555 &self.serves
556 }
557
558 /// Set the line entity ID (used during snapshot restore).
559 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
560 self.entity = entity;
561 }
562
563 /// Mutable access to elevator entities on this line.
564 pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
565 &mut self.elevators
566 }
567
568 /// Mutable access to stop entities served by this line.
569 pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
570 &mut self.serves
571 }
572}
573
574/// How hall calls expose rider destinations to dispatch.
575///
576/// Different building eras and controller designs reveal destinations
577/// at different moments. Groups pick a mode so the sim can model both
578/// traditional up/down collective-control elevators and modern
579/// destination-dispatch lobby kiosks within the same simulation.
580///
581/// Stops are expected to belong to exactly one group. When a stop
582/// overlaps multiple groups, the hall-call press consults the first
583/// group containing it (iteration order over
584/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
585/// turn determines the `HallCallMode` and ack latency applied to that
586/// call. Overlapping topologies are not validated at construction
587/// time; games that need them should be aware of this first-match
588/// rule.
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
590#[non_exhaustive]
591pub enum HallCallMode {
592 /// Traditional collective-control ("classic" Otis/Westinghouse).
593 ///
594 /// Riders press an up or down button in the hall; the destination
595 /// is revealed only *after* boarding, via a
596 /// [`CarCall`]. Dispatch sees a direction
597 /// per call but does not know individual rider destinations until
598 /// they're aboard.
599 #[default]
600 Classic,
601 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
602 /// Polaris, Schindler PORT).
603 ///
604 /// Riders enter their destination at a hall kiosk, so each
605 /// [`HallCall`] carries a destination
606 /// stop from the moment it's pressed. Required by
607 /// [`DestinationDispatch`].
608 Destination,
609}
610
611/// Runtime elevator group: a set of lines sharing a dispatch strategy.
612///
613/// A group is the logical dispatch unit. It contains one or more
614/// [`LineInfo`] entries, each representing a physical path with its
615/// elevators and served stops.
616///
617/// The flat `elevator_entities` and `stop_entities` fields are derived
618/// caches (union of all lines' elevators/stops), rebuilt automatically
619/// via [`rebuild_caches()`](Self::rebuild_caches).
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct ElevatorGroup {
622 /// Unique group identifier.
623 id: GroupId,
624 /// Human-readable group name.
625 name: String,
626 /// Lines belonging to this group.
627 lines: Vec<LineInfo>,
628 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
629 hall_call_mode: HallCallMode,
630 /// Ticks between a button press and dispatch first seeing the call.
631 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
632 /// at 60 Hz, modeling controller processing latency.
633 ack_latency_ticks: u32,
634 /// Derived flat cache — rebuilt by `rebuild_caches()`.
635 elevator_entities: Vec<EntityId>,
636 /// Derived flat cache — rebuilt by `rebuild_caches()`.
637 stop_entities: Vec<EntityId>,
638}
639
640impl ElevatorGroup {
641 /// Create a new group with the given lines. Caches are built automatically.
642 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
643 #[must_use]
644 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
645 let mut group = Self {
646 id,
647 name,
648 lines,
649 hall_call_mode: HallCallMode::default(),
650 ack_latency_ticks: 0,
651 elevator_entities: Vec::new(),
652 stop_entities: Vec::new(),
653 };
654 group.rebuild_caches();
655 group
656 }
657
658 /// Override the hall call mode for this group.
659 #[must_use]
660 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
661 self.hall_call_mode = mode;
662 self
663 }
664
665 /// Override the ack latency for this group.
666 #[must_use]
667 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
668 self.ack_latency_ticks = ticks;
669 self
670 }
671
672 /// Set the hall call mode in-place (for mutation via
673 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
674 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
675 self.hall_call_mode = mode;
676 }
677
678 /// Set the ack latency in-place.
679 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
680 self.ack_latency_ticks = ticks;
681 }
682
683 /// Hall call mode for this group.
684 #[must_use]
685 pub const fn hall_call_mode(&self) -> HallCallMode {
686 self.hall_call_mode
687 }
688
689 /// Controller ack latency for this group.
690 #[must_use]
691 pub const fn ack_latency_ticks(&self) -> u32 {
692 self.ack_latency_ticks
693 }
694
695 /// Unique group identifier.
696 #[must_use]
697 pub const fn id(&self) -> GroupId {
698 self.id
699 }
700
701 /// Human-readable group name.
702 #[must_use]
703 pub fn name(&self) -> &str {
704 &self.name
705 }
706
707 /// Lines belonging to this group.
708 #[must_use]
709 pub fn lines(&self) -> &[LineInfo] {
710 &self.lines
711 }
712
713 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
714 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
715 &mut self.lines
716 }
717
718 /// Elevator entities belonging to this group (derived from lines).
719 #[must_use]
720 pub fn elevator_entities(&self) -> &[EntityId] {
721 &self.elevator_entities
722 }
723
724 /// Stop entities served by this group (derived from lines, deduplicated).
725 #[must_use]
726 pub fn stop_entities(&self) -> &[EntityId] {
727 &self.stop_entities
728 }
729
730 /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
731 /// matches by group id; a `Line(l)` leg matches if `l` belongs to
732 /// this group; `Walk` never rides an elevator.
733 #[must_use]
734 pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
735 match leg.via {
736 crate::components::TransportMode::Group(g) => g == self.id,
737 crate::components::TransportMode::Line(l) => {
738 self.lines.iter().any(|li| li.entity() == l)
739 }
740 crate::components::TransportMode::Walk => false,
741 }
742 }
743
744 /// Push a stop entity directly into the group's stop cache.
745 ///
746 /// Use when a stop belongs to the group for dispatch purposes but is
747 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
748 /// wire it into the topology graph.
749 pub(crate) fn push_stop(&mut self, stop: EntityId) {
750 if !self.stop_entities.contains(&stop) {
751 self.stop_entities.push(stop);
752 }
753 }
754
755 /// Push an elevator entity directly into the group's elevator cache
756 /// (in addition to the line it belongs to).
757 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
758 if !self.elevator_entities.contains(&elevator) {
759 self.elevator_entities.push(elevator);
760 }
761 }
762
763 /// Rebuild derived caches from lines. Call after mutating lines.
764 pub fn rebuild_caches(&mut self) {
765 self.elevator_entities = self
766 .lines
767 .iter()
768 .flat_map(|li| li.elevators.iter().copied())
769 .collect();
770 let mut stops: Vec<EntityId> = self
771 .lines
772 .iter()
773 .flat_map(|li| li.serves.iter().copied())
774 .collect();
775 stops.sort_unstable();
776 stops.dedup();
777 self.stop_entities = stops;
778 }
779}
780
781/// Look up the `serves` list for an elevator's line.
782///
783/// Walks `groups` to find the [`LineInfo`] whose entity matches the
784/// car's current `line()`. Returns `None` if the car has no line
785/// registered in any group (an inconsistent state — should be
786/// unreachable in a healthy sim).
787///
788/// Helper for callers of
789/// [`World::find_stop_at_position_in`](crate::world::World::find_stop_at_position_in)
790/// that already have group context: `find_stop_at_position(pos)` is
791/// global (any line wins) and ambiguous when two lines share a
792/// position; passing the elevator's serves list scopes the lookup to
793/// *its* line.
794///
795/// Cost: `O(groups × lines_per_group)` per call. For loops over many
796/// elevators per tick, prefer [`build_line_serves_index`] +
797/// [`elevator_line_serves_indexed`] to amortize the line walk.
798#[must_use]
799pub fn elevator_line_serves<'a>(
800 world: &World,
801 groups: &'a [ElevatorGroup],
802 elevator: EntityId,
803) -> Option<&'a [EntityId]> {
804 let line_eid = world.elevator(elevator)?.line();
805 groups
806 .iter()
807 .flat_map(ElevatorGroup::lines)
808 .find(|li| li.entity() == line_eid)
809 .map(LineInfo::serves)
810}
811
812/// Pre-built index mapping each line entity to its `serves` slice.
813/// Built once with [`build_line_serves_index`]; queried with
814/// [`elevator_line_serves_indexed`] for O(1) per-elevator lookup.
815pub type LineServesIndex<'a> = std::collections::HashMap<EntityId, &'a [EntityId]>;
816
817/// Build a [`LineServesIndex`] from the group list. O(groups × lines).
818/// Call once per substep / system and reuse across the elevator loop.
819#[must_use]
820pub fn build_line_serves_index(groups: &[ElevatorGroup]) -> LineServesIndex<'_> {
821 let mut idx: LineServesIndex<'_> = std::collections::HashMap::new();
822 for li in groups.iter().flat_map(ElevatorGroup::lines) {
823 idx.insert(li.entity(), li.serves());
824 }
825 idx
826}
827
828/// Indexed variant of [`elevator_line_serves`]. O(1) per call given
829/// a pre-built [`LineServesIndex`].
830#[must_use]
831pub fn elevator_line_serves_indexed<'a>(
832 world: &World,
833 index: &LineServesIndex<'a>,
834 elevator: EntityId,
835) -> Option<&'a [EntityId]> {
836 let line_eid = world.elevator(elevator)?.line();
837 index.get(&line_eid).copied()
838}
839
840/// Context passed to [`DispatchStrategy::rank`].
841///
842/// Bundles the per-call arguments into a single struct so future context
843/// fields can be added without breaking existing trait implementations.
844#[non_exhaustive]
845pub struct RankContext<'a> {
846 /// The elevator being evaluated.
847 pub car: EntityId,
848 /// Current position of the car along the shaft axis.
849 pub car_position: f64,
850 /// The stop being evaluated as a candidate destination.
851 pub stop: EntityId,
852 /// Position of the candidate stop along the shaft axis.
853 pub stop_position: f64,
854 /// The dispatch group this assignment belongs to.
855 pub group: &'a ElevatorGroup,
856 /// Demand snapshot for the current dispatch pass.
857 pub manifest: &'a DispatchManifest,
858 /// Read-only world state.
859 pub world: &'a World,
860}
861
862impl std::fmt::Debug for RankContext<'_> {
863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 f.debug_struct("RankContext")
865 .field("car", &self.car)
866 .field("car_position", &self.car_position)
867 .field("stop", &self.stop)
868 .field("stop_position", &self.stop_position)
869 .field("group", &self.group)
870 .field("manifest", &self.manifest)
871 .field("world", &"World { .. }")
872 .finish()
873 }
874}
875
876/// Pluggable dispatch algorithm.
877///
878/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
879/// pair; the dispatch system then performs an optimal assignment across
880/// the whole group, guaranteeing that no two cars are sent to the same
881/// hall call.
882///
883/// Returning `None` from `rank` excludes a pair from assignment — useful
884/// for capacity limits, direction preferences, restricted stops, or
885/// sticky commitments.
886///
887/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
888/// which returns the policy for that car (idle, park, etc.).
889pub trait DispatchStrategy: Send + Sync {
890 /// Optional hook called once per group before the assignment pass.
891 ///
892 /// Strategies that need to mutate [`World`] extension storage (e.g.
893 /// [`DestinationDispatch`] writing sticky rider → car assignments)
894 /// or pre-populate [`crate::components::DestinationQueue`] entries
895 /// override this. Default: no-op.
896 fn pre_dispatch(
897 &mut self,
898 _group: &ElevatorGroup,
899 _manifest: &DispatchManifest,
900 _world: &mut World,
901 ) {
902 }
903
904 /// Optional hook called once per candidate car, before any
905 /// [`rank`](Self::rank) calls for that car in the current pass.
906 ///
907 /// Strategies whose ranking depends on stable per-car state (e.g. the
908 /// sweep direction used by SCAN/LOOK) set that state here so later
909 /// `rank` calls see a consistent view regardless of iteration order.
910 /// The default is a no-op.
911 fn prepare_car(
912 &mut self,
913 _car: EntityId,
914 _car_position: f64,
915 _group: &ElevatorGroup,
916 _manifest: &DispatchManifest,
917 _world: &World,
918 ) {
919 }
920
921 /// Score the cost of sending `car` to `stop`. Lower is better.
922 ///
923 /// Returning `None` marks this `(car, stop)` pair as unavailable;
924 /// the assignment algorithm will never pair them. Use this for
925 /// capacity limits, wrong-direction stops, stops outside the line's
926 /// topology, or pairs already committed via a sticky assignment.
927 ///
928 /// Must return a finite, non-negative value if `Some` — infinities
929 /// and NaN can destabilize the underlying Hungarian solver.
930 ///
931 /// Takes `&self` so the assignment loop can score `(car, stop)` pairs
932 /// in any order without producing an asymmetric cost matrix. Compute
933 /// any per-car scratch in [`prepare_car`](Self::prepare_car) (which
934 /// takes `&mut self`) before this method is called.
935 fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
936
937 /// Decide what an idle car should do when no stop was assigned to it.
938 ///
939 /// Called for each car the assignment phase could not pair with a
940 /// stop (because there were no stops, or all candidate stops had
941 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
942 fn fallback(
943 &mut self,
944 _car: EntityId,
945 _car_position: f64,
946 _group: &ElevatorGroup,
947 _manifest: &DispatchManifest,
948 _world: &World,
949 ) -> DispatchDecision {
950 DispatchDecision::Idle
951 }
952
953 /// Notify the strategy that an elevator has been removed.
954 ///
955 /// Implementations with per-elevator state (e.g. direction tracking)
956 /// should clean up here to prevent unbounded memory growth.
957 fn notify_removed(&mut self, _elevator: EntityId) {}
958
959 /// If this strategy is a known built-in variant, return it so
960 /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
961 /// correct [`BuiltinStrategy`] into the group's snapshot identity.
962 ///
963 /// Without this, legacy-topology sims constructed via
964 /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
965 /// recorded `BuiltinStrategy::Scan` as their identity — so a
966 /// snapshot round-trip replaced the running strategy with Scan
967 /// and produced different dispatch decisions post-restore
968 /// (determinism regression).
969 ///
970 /// Default: `None` (unidentified — the constructor falls back to
971 /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
972 /// for callers that never cared about round-trip identity). Custom
973 /// strategies that DO care should override this to return
974 /// [`BuiltinStrategy::Custom`] with a stable name.
975 #[must_use]
976 fn builtin_id(&self) -> Option<BuiltinStrategy> {
977 None
978 }
979
980 /// Serialize this strategy's tunable configuration to a string
981 /// that [`restore_config`](Self::restore_config) can apply to a
982 /// freshly-instantiated instance.
983 ///
984 /// Returning `Some(..)` makes the configuration survive snapshot
985 /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
986 /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
987 /// which calls `::new()` with default weights — silently dropping
988 /// any tuning applied via `with_*` builder methods (e.g.
989 /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
990 /// `1.0` on the restored sim).
991 ///
992 /// Default: `None` (no configuration to save). Built-ins with
993 /// tunable weights override to return a RON-serialized copy of
994 /// themselves; strategies with transient per-pass scratch should
995 /// use `#[serde(skip)]` on those fields so the snapshot stays
996 /// compact and deterministic.
997 #[must_use]
998 fn snapshot_config(&self) -> Option<String> {
999 None
1000 }
1001
1002 /// Restore tunable configuration from a string previously produced
1003 /// by [`snapshot_config`](Self::snapshot_config) on the same
1004 /// strategy variant. Called by
1005 /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
1006 /// [`BuiltinStrategy::instantiate`] builds the default instance,
1007 /// so the restore writes over the defaults.
1008 ///
1009 /// # Errors
1010 /// Returns the underlying parse error as a `String` when the
1011 /// serialized form doesn't round-trip. Default implementation
1012 /// ignores the argument and returns `Ok(())` — paired with the
1013 /// `None` default of `snapshot_config`, this means strategies that
1014 /// don't override either method skip configuration round-trip,
1015 /// matching pre-fix behaviour.
1016 fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
1017 Ok(())
1018 }
1019}
1020
1021/// Resolution of a single dispatch assignment pass for one group.
1022///
1023/// Produced by `assign` and consumed by
1024/// `crate::systems::dispatch::run` to apply decisions to the world.
1025#[derive(Debug, Clone)]
1026pub struct AssignmentResult {
1027 /// `(car, decision)` pairs for every idle car in the group.
1028 pub decisions: Vec<(EntityId, DispatchDecision)>,
1029}
1030
1031/// Per-simulation scratch buffers for the dispatch phase.
1032///
1033/// Every field is a `Vec`/`HashSet` whose allocations the hot path
1034/// would otherwise re-take on every tick per group (cost matrix
1035/// backing store, pending-stops list, servicing cars, pinned /
1036/// committed / idle-elevator filters). Owning them on the
1037/// simulation lets each dispatch pass `clear()` them in place and
1038/// reuse the capacity — on a 50-car × 200-stop group the cost matrix
1039/// alone is ~80 KB of heap churn per tick, and at the 500-car
1040/// `scaling_extreme` scale it's ~20 MB.
1041///
1042/// The scratch is always cleared before use; consumers should not
1043/// rely on any carry-over content between groups or ticks.
1044#[derive(Default)]
1045pub(crate) struct DispatchScratch {
1046 /// Reusable `Matrix<i64>` the Hungarian consumes by reference. When
1047 /// the dispatch pass can reuse the stored Matrix (`rows × cols`
1048 /// match), this is `Some` and gets filled in-place via `Matrix::fill`;
1049 /// when shapes change the slot is replaced with `Matrix::new`.
1050 pub cost_matrix_mx: Option<pathfinding::matrix::Matrix<i64>>,
1051 /// `(stop, line, remaining_capacity)` for door-cycling cars, used
1052 /// by `pending_stops_minus_covered` to avoid double-dispatching
1053 /// stops a car is already servicing.
1054 pub servicing: Vec<(EntityId, EntityId, f64)>,
1055 /// Stops with live demand, returned from `pending_stops_minus_covered`.
1056 pub pending_stops: Vec<(EntityId, f64)>,
1057 /// Aboard-rider destinations across idle cars — consulted so a
1058 /// stop that a car aboard wants to reach stays pickup-eligible.
1059 pub idle_rider_destinations: HashSet<EntityId>,
1060 /// Per-stop linestamp buffer reused inside `is_covered`.
1061 pub lines_here: Vec<EntityId>,
1062 /// Pinned hall-call `(car, stop)` pairs for the current group.
1063 pub pinned_pairs: Vec<(EntityId, EntityId)>,
1064 /// Committed `(car, target)` pairs — mid-flight cars whose trip
1065 /// still has demand; held out of the Hungarian idle pool.
1066 pub committed_pairs: Vec<(EntityId, EntityId)>,
1067 /// Idle elevator pool `(car, position)` for this group.
1068 pub idle_elevators: Vec<(EntityId, f64)>,
1069}
1070
1071impl DispatchScratch {
1072 /// Clear every buffer without freeing its backing capacity.
1073 ///
1074 /// `cost_matrix_mx` is re-sized/re-filled lazily in
1075 /// `assign_with_scratch`; leaving it alone here preserves its
1076 /// capacity when the group's (rows, cols) match the last
1077 /// dispatch pass.
1078 pub fn clear_all(&mut self) {
1079 self.servicing.clear();
1080 self.pending_stops.clear();
1081 self.idle_rider_destinations.clear();
1082 self.lines_here.clear();
1083 self.pinned_pairs.clear();
1084 self.committed_pairs.clear();
1085 self.idle_elevators.clear();
1086 }
1087}
1088
1089/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
1090/// building the cost matrix for the Hungarian solver. Chosen so that
1091/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
1092/// sums weights and potentials across each row/column internally, so
1093/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
1094/// cars or stops before any arithmetic risk appears.
1095const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
1096/// Fixed-point scale for converting `f64` costs to the `i64` values the
1097/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter.
1098const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
1099
1100/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
1101/// solver consumes. Non-finite, negative, or overflow-prone inputs map
1102/// to the unavailable sentinel.
1103fn scale_cost(cost: f64) -> i64 {
1104 if !cost.is_finite() || cost < 0.0 {
1105 debug_assert!(
1106 cost.is_finite() && cost >= 0.0,
1107 "DispatchStrategy::rank() returned invalid cost {cost}; must be finite and non-negative"
1108 );
1109 return ASSIGNMENT_SENTINEL;
1110 }
1111 // Cap at just below sentinel so any real rank always beats unavailable.
1112 (cost * ASSIGNMENT_SCALE)
1113 .round()
1114 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
1115}
1116
1117/// Build the pending-demand stop list, subtracting stops whose
1118/// demand is already being absorbed by a car — either currently in
1119/// its door cycle at the stop, or en route via `MovingToStop`.
1120///
1121/// Both phases count as "servicing" because they represent a
1122/// commitment to open doors at the target with remaining capacity
1123/// that waiting riders can (typically) fit into. Without the
1124/// `MovingToStop` case, a new idle car becoming available during
1125/// car A's trip to the lobby gets paired with the same lobby call
1126/// on the next dispatch tick — car B travels empty behind car A
1127/// and the playground shows two cars doing a lobby touch-and-go
1128/// for one rider. Composes with the commitment set in
1129/// [`systems::dispatch`](crate::systems::dispatch), which excludes
1130/// committed cars from the idle pool at the same time.
1131///
1132/// `Stopped` (parked-with-doors-closed) is deliberately *not* in
1133/// the list: that's a legitimately reassignable state.
1134/// `Repositioning` is also excluded — a repositioning car doesn't
1135/// open doors on arrival, so it cannot absorb waiting riders.
1136///
1137/// Line-pinned riders (`TransportMode::Line(L)`) keep a stop
1138/// pending even when a car is present, because a car on Shaft A
1139/// can't absorb a rider pinned to Shaft B. Coverage also fails
1140/// when the waiting riders' combined weight exceeds the servicing
1141/// car's remaining capacity — the leftover spills out when doors
1142/// close and deserves its own dispatch immediately.
1143fn pending_stops_minus_covered(
1144 group: &ElevatorGroup,
1145 manifest: &DispatchManifest,
1146 world: &World,
1147 idle_cars: &[(EntityId, f64)],
1148 scratch: &mut DispatchScratch,
1149) {
1150 // Refill `scratch.servicing` in place — the buffer survives across
1151 // ticks so the hot path doesn't reallocate per group.
1152 scratch.servicing.clear();
1153 for &eid in group.elevator_entities() {
1154 let Some(car) = world.elevator(eid) else {
1155 continue;
1156 };
1157 let Some(target) = car.target_stop() else {
1158 continue;
1159 };
1160 if !matches!(
1161 car.phase(),
1162 ElevatorPhase::MovingToStop(_)
1163 | ElevatorPhase::DoorOpening
1164 | ElevatorPhase::Loading
1165 | ElevatorPhase::DoorClosing
1166 ) {
1167 continue;
1168 }
1169 let remaining = car.weight_capacity().value() - car.current_load().value();
1170 scratch.servicing.push((target, car.line(), remaining));
1171 }
1172
1173 // Aboard-rider destinations — reused buffer, same owned semantics.
1174 scratch.idle_rider_destinations.clear();
1175 for &(car_eid, _) in idle_cars {
1176 if let Some(car) = world.elevator(car_eid) {
1177 for &rid in car.riders() {
1178 if let Some(dest) = world.route(rid).and_then(Route::current_destination) {
1179 scratch.idle_rider_destinations.insert(dest);
1180 }
1181 }
1182 }
1183 }
1184
1185 // A stop is "covered" iff every waiting rider this group sees can
1186 // board at least one of the door-cycling cars here (line check)
1187 // AND the combined remaining capacity of the cars whose line
1188 // accepts the rider is enough to board them all (capacity check).
1189 //
1190 // Iterates `manifest.waiting_riders_at` rather than `world.iter_riders`
1191 // so `TransportMode::Walk` riders and cross-group-routed riders
1192 // (excluded by `build_manifest`) don't inflate the weight total.
1193 // `lines_here` is the same `scratch.lines_here` buffer each call —
1194 // cleared then refilled — so coverage checks don't churn the
1195 // allocator per stop.
1196 let mut lines_here: Vec<EntityId> = std::mem::take(&mut scratch.lines_here);
1197 let servicing = &scratch.servicing;
1198 let is_covered = |stop_eid: EntityId, lines_here: &mut Vec<EntityId>| -> bool {
1199 lines_here.clear();
1200 let mut capacity_here = 0.0;
1201 for &(stop, line, rem) in servicing {
1202 if stop == stop_eid {
1203 lines_here.push(line);
1204 capacity_here += rem;
1205 }
1206 }
1207 if lines_here.is_empty() {
1208 return false;
1209 }
1210 let mut total_weight = 0.0;
1211 for rider in manifest.waiting_riders_at(stop_eid) {
1212 let required_line = world
1213 .route(rider.id)
1214 .and_then(Route::current)
1215 .and_then(|leg| match leg.via {
1216 TransportMode::Line(l) => Some(l),
1217 _ => None,
1218 });
1219 if let Some(required) = required_line
1220 && !lines_here.contains(&required)
1221 {
1222 return false;
1223 }
1224 total_weight += rider.weight.value();
1225 }
1226 total_weight <= capacity_here
1227 };
1228
1229 scratch.pending_stops.clear();
1230 for &stop in group.stop_entities() {
1231 if !manifest.has_demand(stop) {
1232 continue;
1233 }
1234 let keep =
1235 scratch.idle_rider_destinations.contains(&stop) || !is_covered(stop, &mut lines_here);
1236 if !keep {
1237 continue;
1238 }
1239 if let Some(pos) = world.stop_position(stop) {
1240 scratch.pending_stops.push((stop, pos));
1241 }
1242 }
1243 // Return the lines_here buffer to scratch so its capacity survives.
1244 scratch.lines_here = lines_here;
1245}
1246
1247/// Run one group's assignment pass: build the cost matrix, solve the
1248/// optimal bipartite matching, then resolve unassigned cars via
1249/// [`DispatchStrategy::fallback`].
1250///
1251/// Visible to the `systems` module; not part of the public API.
1252/// Back-compat wrapper that allocates a throw-away scratch for
1253/// tests and one-off callers. Production paths (in
1254/// `crate::systems::dispatch::run`) must use
1255/// [`assign_with_scratch`] so the scratch capacity amortises
1256/// across ticks.
1257#[cfg(test)]
1258pub(crate) fn assign(
1259 strategy: &mut dyn DispatchStrategy,
1260 idle_cars: &[(EntityId, f64)],
1261 group: &ElevatorGroup,
1262 manifest: &DispatchManifest,
1263 world: &World,
1264) -> AssignmentResult {
1265 let mut scratch = DispatchScratch::default();
1266 assign_with_scratch(strategy, idle_cars, group, manifest, world, &mut scratch)
1267}
1268
1269/// Run one group's assignment pass: build the cost matrix, solve the
1270/// optimal bipartite matching, then resolve unassigned cars via
1271/// [`DispatchStrategy::fallback`]. Uses `scratch` so the per-tick
1272/// allocations (cost matrix, pending stops, etc.) reuse capacity
1273/// across invocations.
1274pub(crate) fn assign_with_scratch(
1275 strategy: &mut dyn DispatchStrategy,
1276 idle_cars: &[(EntityId, f64)],
1277 group: &ElevatorGroup,
1278 manifest: &DispatchManifest,
1279 world: &World,
1280 scratch: &mut DispatchScratch,
1281) -> AssignmentResult {
1282 // Fill `scratch.pending_stops` in place. The buffer's capacity
1283 // survives across ticks.
1284 pending_stops_minus_covered(group, manifest, world, idle_cars, scratch);
1285
1286 let n = idle_cars.len();
1287 let m = scratch.pending_stops.len();
1288
1289 if n == 0 {
1290 return AssignmentResult {
1291 decisions: Vec::new(),
1292 };
1293 }
1294
1295 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
1296
1297 if m == 0 {
1298 for &(eid, pos) in idle_cars {
1299 let d = strategy.fallback(eid, pos, group, manifest, world);
1300 decisions.push((eid, d));
1301 }
1302 return AssignmentResult { decisions };
1303 }
1304
1305 // Hungarian requires rows <= cols. Reuse the scratch `Matrix` when
1306 // the shape matches the previous dispatch pass — on a realistic
1307 // building the (rows, cols) tuple changes only when the car or
1308 // stop count does, so steady-state dispatch avoids any heap
1309 // traffic for the cost matrix at all. When the shape does change,
1310 // a fresh Matrix replaces the stored one and becomes the new
1311 // reusable buffer going forward.
1312 let cols = n.max(m);
1313 match &mut scratch.cost_matrix_mx {
1314 Some(mx) if mx.rows == n && mx.columns == cols => {
1315 mx.fill(ASSIGNMENT_SENTINEL);
1316 }
1317 slot => {
1318 *slot = Some(pathfinding::matrix::Matrix::new(
1319 n,
1320 cols,
1321 ASSIGNMENT_SENTINEL,
1322 ));
1323 }
1324 }
1325 let matrix_ref = scratch
1326 .cost_matrix_mx
1327 .as_mut()
1328 .unwrap_or_else(|| unreachable!("cost_matrix_mx populated by match above"));
1329
1330 {
1331 let pending_stops = &scratch.pending_stops;
1332 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1333 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
1334 // Borrow the car's restricted-stops set for this row so each
1335 // (car, stop) pair can short-circuit before calling rank().
1336 // Pre-fix only DCS consulted restricted_stops; SCAN/LOOK/NC/ETD
1337 // happily ranked restricted pairs and `commit_go_to_stop` later
1338 // silently dropped the assignment, starving the call. (#256)
1339 let restricted = world
1340 .elevator(car_eid)
1341 .map(crate::components::Elevator::restricted_stops);
1342
1343 // The car's line's `serves` list is the set of stops it can
1344 // physically reach. In a single-line group every stop is
1345 // served (filter is a no-op); in a multi-line group (e.g.
1346 // sky-lobby + service bank, low/high banks sharing a
1347 // transfer floor) a car on line A must not be assigned to
1348 // a stop only line B serves — it would commit, sit there
1349 // unable to reach, and starve the call. The pre-fix matrix
1350 // happily ranked such cross-line pairs because no other
1351 // gate caught them: `restricted_stops` is for explicit
1352 // access denials, `pending_stops_minus_covered` filters
1353 // stops not cars, and built-in strategies score on
1354 // distance/direction without consulting line topology.
1355 let car_serves: Option<&[EntityId]> = world
1356 .elevator(car_eid)
1357 .map(crate::components::Elevator::line)
1358 .and_then(|line_eid| {
1359 group
1360 .lines()
1361 .iter()
1362 .find(|li| li.entity() == line_eid)
1363 .map(LineInfo::serves)
1364 });
1365 // `None` here means the car's line isn't in this group's
1366 // line list — a topology inconsistency that should be
1367 // unreachable. We can't fail the dispatch tick over it (the
1368 // sim still has to make progress), so the filter falls
1369 // open: the car is treated as if it could reach any stop.
1370 // The debug-assert catches it during testing without
1371 // affecting release builds.
1372 debug_assert!(
1373 world.elevator(car_eid).is_none() || car_serves.is_some(),
1374 "car {car_eid:?} on line not present in its group's lines list"
1375 );
1376
1377 for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
1378 if restricted.is_some_and(|r| r.contains(&stop_eid)) {
1379 continue; // leave SENTINEL — this pair is unavailable
1380 }
1381 if car_serves.is_some_and(|s| !s.contains(&stop_eid)) {
1382 continue; // car's line doesn't reach this stop
1383 }
1384 let ctx = RankContext {
1385 car: car_eid,
1386 car_position: car_pos,
1387 stop: stop_eid,
1388 stop_position: stop_pos,
1389 group,
1390 manifest,
1391 world,
1392 };
1393 let scaled = strategy.rank(&ctx).map_or(ASSIGNMENT_SENTINEL, scale_cost);
1394 matrix_ref[(i, j)] = scaled;
1395 }
1396 }
1397 }
1398 let matrix = &*matrix_ref;
1399 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(matrix);
1400
1401 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
1402 let col = assignments[i];
1403 // A real assignment is: col points to a real stop (col < m) AND
1404 // the cost isn't sentinel-padded (meaning rank() returned Some).
1405 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
1406 let (stop_eid, _) = scratch.pending_stops[col];
1407 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
1408 } else {
1409 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
1410 decisions.push((car_eid, d));
1411 }
1412 }
1413
1414 AssignmentResult { decisions }
1415}
1416
1417/// Pluggable strategy for repositioning idle elevators.
1418///
1419/// After the dispatch phase, elevators that remain idle (no pending
1420/// assignments) are candidates for repositioning. The strategy decides
1421/// where each idle elevator should move to improve coverage and reduce
1422/// expected response times.
1423///
1424/// Implementations receive the set of idle elevator positions and the
1425/// group's stop positions, then return a target stop for each elevator
1426/// (or `None` to leave it in place).
1427pub trait RepositionStrategy: Send + Sync {
1428 /// Decide where to reposition idle elevators.
1429 ///
1430 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1431 /// The buffer is cleared before each call — implementations should
1432 /// only push, never read prior contents. Elevators not pushed remain idle.
1433 fn reposition(
1434 &mut self,
1435 idle_elevators: &[(EntityId, f64)],
1436 stop_positions: &[(EntityId, f64)],
1437 group: &ElevatorGroup,
1438 world: &World,
1439 out: &mut Vec<(EntityId, EntityId)>,
1440 );
1441
1442 /// If this strategy is a known built-in variant, return it so
1443 /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1444 /// callers don't have to pass a separate [`BuiltinReposition`] id
1445 /// that might drift from the dispatcher's actual type.
1446 ///
1447 /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1448 /// in #410: the runtime impl identifies itself so the snapshot
1449 /// identity always matches the executing behaviour, instead of
1450 /// depending on the caller to keep two parameters consistent.
1451 /// Default `None` — custom strategies should override to return
1452 /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1453 /// fidelity.
1454 #[must_use]
1455 fn builtin_id(&self) -> Option<BuiltinReposition> {
1456 None
1457 }
1458
1459 /// Minimum [`ArrivalLog`](crate::arrival_log::ArrivalLog) retention
1460 /// (in ticks) the strategy needs to function. Strategies that read
1461 /// the log directly with a custom rolling window must override this
1462 /// so [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1463 /// can widen
1464 /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1465 /// to keep the data alive long enough for the query.
1466 ///
1467 /// Default `0` — strategies that don't read the arrival log (or that
1468 /// only consume it through [`DispatchManifest::arrivals_at`], which
1469 /// already tracks retention) impose no requirement.
1470 #[must_use]
1471 fn min_arrival_log_window(&self) -> u64 {
1472 0
1473 }
1474}
1475
1476/// Serializable identifier for built-in repositioning strategies.
1477///
1478/// Used in config and snapshots to restore the correct strategy.
1479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1480#[non_exhaustive]
1481pub enum BuiltinReposition {
1482 /// Distribute idle elevators evenly across stops.
1483 SpreadEvenly,
1484 /// Return idle elevators to a configured home stop.
1485 ReturnToLobby,
1486 /// Position near stops with historically high demand.
1487 DemandWeighted,
1488 /// Keep idle elevators where they are (no-op).
1489 NearestIdle,
1490 /// Pre-position cars near stops with the highest recent arrival rate.
1491 PredictiveParking,
1492 /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1493 /// based on the current `TrafficDetector` mode.
1494 Adaptive,
1495 /// Custom strategy identified by name.
1496 Custom(String),
1497}
1498
1499impl std::fmt::Display for BuiltinReposition {
1500 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1501 match self {
1502 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1503 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1504 Self::DemandWeighted => write!(f, "DemandWeighted"),
1505 Self::NearestIdle => write!(f, "NearestIdle"),
1506 Self::PredictiveParking => write!(f, "PredictiveParking"),
1507 Self::Adaptive => write!(f, "Adaptive"),
1508 Self::Custom(name) => write!(f, "Custom({name})"),
1509 }
1510 }
1511}
1512
1513impl BuiltinReposition {
1514 /// Instantiate the reposition strategy for this variant.
1515 ///
1516 /// Returns `None` for `Custom` — the game must provide those via
1517 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1518 #[must_use]
1519 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1520 match self {
1521 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1522 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1523 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1524 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1525 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1526 Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1527 Self::Custom(_) => None,
1528 }
1529 }
1530}