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/// Hungarian-assignment pass + per-pass scratch buffers.
37pub(crate) mod assignment;
38/// Hall-call destination dispatch algorithm.
39pub mod destination;
40/// Estimated Time to Destination dispatch algorithm.
41pub mod etd;
42/// LOOK dispatch algorithm.
43pub mod look;
44/// Per-tick demand picture handed to dispatch strategies.
45pub mod manifest;
46/// Nearest-car dispatch algorithm.
47pub mod nearest_car;
48/// Built-in repositioning strategies.
49pub mod reposition;
50/// Relative System Response (RSR) dispatch algorithm.
51pub mod rsr;
52/// SCAN dispatch algorithm.
53pub mod scan;
54/// Per-elevator scratch helper for custom strategies.
55pub mod scratch;
56/// Shared sweep-direction logic used by SCAN and LOOK.
57pub(crate) mod sweep;
58
59pub use assignment::AssignmentResult;
60#[cfg(test)]
61pub(crate) use assignment::assign;
62pub(crate) use assignment::{DispatchScratch, assign_with_scratch};
63pub use destination::{AssignedCar, DestinationDispatch};
64pub use etd::EtdDispatch;
65pub use look::LookDispatch;
66pub use manifest::{DispatchManifest, RiderInfo};
67pub use nearest_car::NearestCarDispatch;
68pub use rsr::RsrDispatch;
69pub use scan::ScanDispatch;
70pub use scratch::PrepareScratch;
71
72use serde::{Deserialize, Serialize};
73
74use crate::components::Route;
75use crate::entity::EntityId;
76use crate::ids::GroupId;
77use crate::world::World;
78
79/// Whether assigning `ctx.car` to `ctx.stop` is worth ranking.
80///
81/// Combines two checks every dispatch strategy needs at the top of its
82/// `rank` implementation:
83///
84/// 1. **Servability** — capacity, full-load bypass, and the loading-phase
85/// boarding filter. A pair that can't exit an aboard rider, board a
86/// waiter, or answer a rider-less hall call is a no-op move (and a
87/// zero-cost one when the car is already parked there) which would
88/// otherwise stall doors against unservable demand.
89/// 2. **Path discipline** (only when `respect_aboard_path` is `true`) —
90/// refuses pickups that would pull a car carrying routed riders off
91/// the direct path to every aboard rider's destination. Without it, a
92/// stream of closer-destination hall calls can indefinitely preempt a
93/// farther aboard rider's delivery (the "never reaches the
94/// passenger's desired stop" loop).
95///
96/// Strategies with their own direction discipline (SCAN, LOOK, ETD,
97/// Destination) pass `respect_aboard_path: false` because their
98/// sweep/direction terms already rule out backtracks. Strategies without
99/// it (`NearestCar`, RSR) pass `respect_aboard_path: true`. Custom
100/// strategies should pass `true` unless they enforce direction
101/// discipline themselves.
102///
103/// Aboard riders without a published route (game-managed manual riders)
104/// don't constrain the path — any pickup is trivially on-the-way for
105/// them, so the path check trivially passes when no aboard rider has a
106/// `Route::current_destination`.
107#[must_use]
108pub fn pair_is_useful(ctx: &RankContext<'_>, respect_aboard_path: bool) -> bool {
109 let Some(car) = ctx.world.elevator(ctx.car) else {
110 return false;
111 };
112 let can_exit_here = car
113 .riders()
114 .iter()
115 .any(|&rid| ctx.world.route(rid).and_then(Route::current_destination) == Some(ctx.stop));
116 if can_exit_here {
117 return true;
118 }
119
120 // Direction-dependent full-load bypass (Otis Elevonic 411 model,
121 // patent US5490580A). A car loaded above its configured threshold
122 // in the current travel direction ignores hall calls in that same
123 // direction. Aboard riders still get delivered — the `can_exit_here`
124 // short-circuit above guarantees their destinations remain rank-able.
125 if bypass_in_current_direction(car, ctx) {
126 return false;
127 }
128
129 let remaining_capacity = car.weight_capacity.value() - car.current_load.value();
130 if remaining_capacity <= 0.0 {
131 return false;
132 }
133 let waiting = ctx.manifest.waiting_riders_at(ctx.stop);
134 let servable = if waiting.is_empty() {
135 // No waiters at the stop, and no aboard rider of ours exits here
136 // (the `can_exit_here` short-circuit ruled that out above).
137 // Demand must therefore come from either another car's
138 // `riding_to_stop` (not work this car can perform) or a
139 // rider-less hall call (someone pressed a button with no rider
140 // attached yet — a press from `press_hall_button` or one whose
141 // riders have since been fulfilled or abandoned). Only the
142 // latter is actionable; without this filter an idle car parked
143 // at the stop collapses to cost 0, the Hungarian picks the
144 // self-pair every tick, and doors cycle open/close indefinitely
145 // while the other car finishes its trip.
146 ctx.manifest
147 .hall_calls_at_stop
148 .get(&ctx.stop)
149 .is_some_and(|calls| calls.iter().any(|c| c.pending_riders.is_empty()))
150 } else {
151 waiting
152 .iter()
153 .any(|r| rider_can_board(r, car, ctx, remaining_capacity))
154 };
155 if !servable {
156 return false;
157 }
158 if !respect_aboard_path || car.riders().is_empty() {
159 return true;
160 }
161
162 // Route-less aboard riders (game-managed manual riders) don't
163 // publish a destination, so there's no committed path to protect.
164 // Any pickup is trivially on-the-way — let it through. Otherwise
165 // we'd refuse every pickup the moment the car carried its first
166 // manually-managed passenger.
167 let has_routed_rider = car.riders().iter().any(|&rid| {
168 ctx.world
169 .route(rid)
170 .and_then(Route::current_destination)
171 .is_some()
172 });
173 if !has_routed_rider {
174 return true;
175 }
176
177 // Pickups allowed only on the path to an aboard rider's destination.
178 // Candidate at the car's position (to_cand = 0) trivially qualifies —
179 // useful for same-floor boards.
180 let to_cand = ctx.stop_position() - ctx.car_position();
181 car.riders().iter().any(|&rid| {
182 let Some(dest) = ctx.world.route(rid).and_then(Route::current_destination) else {
183 return false;
184 };
185 let Some(dest_pos) = ctx.world.stop_position(dest) else {
186 return false;
187 };
188 let to_dest = dest_pos - ctx.car_position();
189 to_dest * to_cand >= 0.0 && to_cand.abs() <= to_dest.abs()
190 })
191}
192
193/// Sum of `wait_ticks` across `riders`, as `f64`.
194///
195/// Helper used by ETD and RSR fairness terms — both compute the same
196/// `riders.iter().map(|r| r.wait_ticks as f64).sum()` and feed the
197/// result into a fused-multiply-add against a configured weight.
198#[must_use]
199pub(crate) fn wait_ticks_sum(riders: &[RiderInfo]) -> f64 {
200 riders.iter().map(|r| r.wait_ticks as f64).sum()
201}
202
203/// Sum of squared `wait_ticks` across `riders`, as `f64`.
204///
205/// Used by ETD's quadratic-fairness term to escalate cost as old
206/// waiters age. RSR has no quadratic fairness; the linear form lives
207/// in [`wait_ticks_sum`].
208#[must_use]
209pub(crate) fn wait_ticks_squared_sum(riders: &[RiderInfo]) -> f64 {
210 riders
211 .iter()
212 .map(|r| {
213 let w = r.wait_ticks as f64;
214 w * w
215 })
216 .sum()
217}
218
219/// Apply a fairness bonus to a dispatch cost, clamping at zero.
220///
221/// Computes `(cost - weight * term).max(0.0)` via [`fp::fma`] for
222/// tighter rounding than the manual `cost - weight * term` form when
223/// the multiplier and product are both finite.
224///
225/// Both ETD's `age_linear` / `wait_squared` weights and RSR's
226/// `age_linear_weight` use this exact shape — a non-negative
227/// `weight` scaled against a non-negative aggregate (`wait_ticks_sum`
228/// / `wait_ticks_squared_sum`) subtracted from the running cost. The
229/// `.max(0.0)` floor is mandatory because the Hungarian assignment
230/// requires non-negative costs; without it, deeply-aged waits could
231/// underflow the cost into the negative territory, where the assigner's
232/// row-reduction step would produce a smaller-than-zero pseudo-cost
233/// and silently mis-rank.
234///
235/// [`fp::fma`]: crate::fp::fma
236#[must_use]
237pub(crate) fn apply_fairness_bonus(cost: f64, weight: f64, term: f64) -> f64 {
238 crate::fp::fma(weight, -term, cost).max(0.0)
239}
240
241/// Whether a waiting rider could actually board this car, matching the
242/// same filters the loading phase applies. Prevents `pair_is_useful`
243/// from approving a pickup whose only demand is direction-filtered or
244/// over-capacity — the loading phase would reject the rider, doors
245/// would cycle, and dispatch would re-pick the zero-cost self-pair.
246fn rider_can_board(
247 rider: &RiderInfo,
248 car: &crate::components::Elevator,
249 ctx: &RankContext<'_>,
250 remaining_capacity: f64,
251) -> bool {
252 if rider.weight.value() > remaining_capacity {
253 return false;
254 }
255 // Match `systems::loading`'s direction filter: a rider whose trip
256 // goes the opposite way of the car's committed direction will not
257 // be boarded. An unknown destination (no route yet) is treated as
258 // unconstrained — let the rider through and let the loading phase
259 // make the final call.
260 let Some(dest) = rider.destination else {
261 return true;
262 };
263 let Some(dest_pos) = ctx.world.stop_position(dest) else {
264 return true;
265 };
266 if dest_pos > ctx.stop_position() && !car.going_up() {
267 return false;
268 }
269 if dest_pos < ctx.stop_position() && !car.going_down() {
270 return false;
271 }
272 true
273}
274
275/// True when a full-load bypass applies: the car has a configured
276/// threshold for its current travel direction, is above that threshold,
277/// and the candidate stop lies in that same direction.
278fn bypass_in_current_direction(car: &crate::components::Elevator, ctx: &RankContext<'_>) -> bool {
279 // Derive travel direction from the car's current target, if any.
280 // An Idle or Stopped car has no committed direction → no bypass.
281 let Some(target) = car.phase().moving_target() else {
282 return false;
283 };
284 let Some(target_pos) = ctx.world.stop_position(target) else {
285 return false;
286 };
287 let going_up = target_pos > ctx.car_position();
288 let going_down = target_pos < ctx.car_position();
289 if !going_up && !going_down {
290 return false;
291 }
292 let threshold = if going_up {
293 car.bypass_load_up_pct()
294 } else {
295 car.bypass_load_down_pct()
296 };
297 let Some(pct) = threshold else {
298 return false;
299 };
300 let capacity = car.weight_capacity().value();
301 if capacity <= 0.0 {
302 return false;
303 }
304 let load_ratio = car.current_load().value() / capacity;
305 if load_ratio < pct {
306 return false;
307 }
308 // Only same-direction pickups get bypassed.
309 let stop_above = ctx.stop_position() > ctx.car_position();
310 let stop_below = ctx.stop_position() < ctx.car_position();
311 (going_up && stop_above) || (going_down && stop_below)
312}
313
314/// Serializable identifier for built-in dispatch strategies.
315///
316/// Used in snapshots and config files to restore the correct strategy
317/// without requiring the game to manually re-wire dispatch. Custom strategies
318/// are represented by the `Custom(String)` variant.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[non_exhaustive]
321pub enum BuiltinStrategy {
322 /// SCAN (elevator) algorithm — sweeps end-to-end.
323 Scan,
324 /// LOOK algorithm — reverses at last request.
325 Look,
326 /// Nearest-car — assigns closest idle elevator.
327 NearestCar,
328 /// Estimated Time to Destination — minimizes total cost.
329 Etd,
330 /// Hall-call destination dispatch — sticky per-rider assignment.
331 Destination,
332 /// Relative System Response — additive composite of ETA, direction,
333 /// car-call affinity, and load-share terms.
334 Rsr,
335 /// Custom strategy identified by name. The game must provide a factory.
336 Custom(String),
337}
338
339impl std::fmt::Display for BuiltinStrategy {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 match self {
342 Self::Scan => write!(f, "Scan"),
343 Self::Look => write!(f, "Look"),
344 Self::NearestCar => write!(f, "NearestCar"),
345 Self::Etd => write!(f, "Etd"),
346 Self::Destination => write!(f, "Destination"),
347 Self::Rsr => write!(f, "Rsr"),
348 Self::Custom(name) => write!(f, "Custom({name})"),
349 }
350 }
351}
352
353impl BuiltinStrategy {
354 /// Instantiate the dispatch strategy for this variant.
355 ///
356 /// Returns `None` for `Custom` — the game must provide those via
357 /// a factory function.
358 #[must_use]
359 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
360 match self {
361 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
362 Self::Look => Some(Box::new(look::LookDispatch::new())),
363 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
364 // `Default` ships the tuned stack (age-linear fairness term
365 // active); `new()` is the zero baseline for mutant/unit
366 // tests that isolate single terms. The playground's "ETD"
367 // dropdown entry should map to the strategy with fairness
368 // protection, not the raw version that lets the max-wait
369 // tail drift unbounded.
370 Self::Etd => Some(Box::new(etd::EtdDispatch::default())),
371 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
372 // `Default` ships with the tuned penalty stack; `new()` is
373 // the zero baseline for additive-composition tests. The
374 // playground's "RSR" dropdown entry should map to the
375 // actual strategy, not to NearestCar-in-disguise, so use
376 // `Default` here.
377 Self::Rsr => Some(Box::new(rsr::RsrDispatch::default())),
378 Self::Custom(_) => None,
379 }
380 }
381}
382
383/// Decision returned by a dispatch strategy.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[non_exhaustive]
386pub enum DispatchDecision {
387 /// Go to the specified stop entity.
388 GoToStop(EntityId),
389 /// Remain idle.
390 Idle,
391}
392
393/// Per-line relationship data within an [`ElevatorGroup`].
394///
395/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
396/// The source of truth for intrinsic line properties is the
397/// [`Line`](crate::components::Line) component in World.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct LineInfo {
400 /// Line entity ID.
401 entity: EntityId,
402 /// Elevator entities on this line.
403 elevators: Vec<EntityId>,
404 /// Stop entities served by this line.
405 serves: Vec<EntityId>,
406}
407
408impl LineInfo {
409 /// Create a new `LineInfo`.
410 #[must_use]
411 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
412 Self {
413 entity,
414 elevators,
415 serves,
416 }
417 }
418
419 /// Line entity ID.
420 #[must_use]
421 pub const fn entity(&self) -> EntityId {
422 self.entity
423 }
424
425 /// Elevator entities on this line.
426 #[must_use]
427 pub fn elevators(&self) -> &[EntityId] {
428 &self.elevators
429 }
430
431 /// Stop entities served by this line.
432 #[must_use]
433 pub fn serves(&self) -> &[EntityId] {
434 &self.serves
435 }
436
437 /// Set the line entity ID (used during snapshot restore).
438 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
439 self.entity = entity;
440 }
441
442 /// Add an elevator to this line, deduplicating against existing entries.
443 ///
444 /// Returns `true` if the elevator was inserted, `false` if it was
445 /// already present. Replaces direct `&mut Vec` access so callers
446 /// can't introduce duplicates the dedup invariants in
447 /// [`ElevatorGroup::rebuild_caches`] rely on.
448 pub(crate) fn add_elevator(&mut self, elevator: EntityId) -> bool {
449 if self.elevators.contains(&elevator) {
450 false
451 } else {
452 self.elevators.push(elevator);
453 true
454 }
455 }
456
457 /// Remove an elevator from this line.
458 ///
459 /// Returns `true` if the elevator was present and removed, `false`
460 /// if it was absent.
461 pub(crate) fn remove_elevator(&mut self, elevator: EntityId) -> bool {
462 let len_before = self.elevators.len();
463 self.elevators.retain(|&e| e != elevator);
464 self.elevators.len() != len_before
465 }
466
467 /// Add a stop to this line's served list, deduplicating against
468 /// existing entries.
469 ///
470 /// Returns `true` if the stop was inserted, `false` if it was
471 /// already present.
472 pub(crate) fn add_stop(&mut self, stop: EntityId) -> bool {
473 if self.serves.contains(&stop) {
474 false
475 } else {
476 self.serves.push(stop);
477 true
478 }
479 }
480
481 /// Remove a stop from this line's served list.
482 ///
483 /// Returns `true` if the stop was present and removed, `false`
484 /// if it was absent.
485 pub(crate) fn remove_stop(&mut self, stop: EntityId) -> bool {
486 let len_before = self.serves.len();
487 self.serves.retain(|&s| s != stop);
488 self.serves.len() != len_before
489 }
490}
491
492/// How hall calls expose rider destinations to dispatch.
493///
494/// Different building eras and controller designs reveal destinations
495/// at different moments. Groups pick a mode so the sim can model both
496/// traditional up/down collective-control elevators and modern
497/// destination-dispatch lobby kiosks within the same simulation.
498///
499/// Stops are expected to belong to exactly one group. When a stop
500/// overlaps multiple groups, the hall-call press consults the first
501/// group containing it (iteration order over
502/// [`Simulation::groups`](crate::sim::Simulation::groups)), which in
503/// turn determines the `HallCallMode` and ack latency applied to that
504/// call. Overlapping topologies are not validated at construction
505/// time; games that need them should be aware of this first-match
506/// rule.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
508#[non_exhaustive]
509pub enum HallCallMode {
510 /// Traditional collective-control ("classic" Otis/Westinghouse).
511 ///
512 /// Riders press an up or down button in the hall; the destination
513 /// is revealed only *after* boarding, via a
514 /// [`CarCall`](crate::components::CarCall). Dispatch sees a direction
515 /// per call but does not know individual rider destinations until
516 /// they're aboard.
517 #[default]
518 Classic,
519 /// Modern destination dispatch ("DCS" — Otis `CompassPlus`, KONE
520 /// Polaris, Schindler PORT).
521 ///
522 /// Riders enter their destination at a hall kiosk, so each
523 /// [`HallCall`](crate::components::HallCall) carries a destination
524 /// stop from the moment it's pressed. Required by
525 /// [`DestinationDispatch`].
526 Destination,
527}
528
529impl std::fmt::Display for HallCallMode {
530 /// ```
531 /// # use elevator_core::dispatch::HallCallMode;
532 /// assert_eq!(format!("{}", HallCallMode::Classic), "classic");
533 /// assert_eq!(format!("{}", HallCallMode::Destination), "destination");
534 /// ```
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 match self {
537 Self::Classic => f.write_str("classic"),
538 Self::Destination => f.write_str("destination"),
539 }
540 }
541}
542
543/// Runtime elevator group: a set of lines sharing a dispatch strategy.
544///
545/// A group is the logical dispatch unit. It contains one or more
546/// [`LineInfo`] entries, each representing a physical path with its
547/// elevators and served stops.
548///
549/// The flat `elevator_entities` and `stop_entities` fields are derived
550/// caches (union of all lines' elevators/stops), rebuilt automatically
551/// via [`rebuild_caches()`](Self::rebuild_caches).
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ElevatorGroup {
554 /// Unique group identifier.
555 id: GroupId,
556 /// Human-readable group name.
557 name: String,
558 /// Lines belonging to this group.
559 lines: Vec<LineInfo>,
560 /// How hall calls reveal destinations to dispatch (Classic vs DCS).
561 hall_call_mode: HallCallMode,
562 /// Ticks between a button press and dispatch first seeing the call.
563 /// `0` = immediate (current behavior). Realistic values: 5–30 ticks
564 /// at 60 Hz, modeling controller processing latency.
565 ack_latency_ticks: u32,
566 /// Derived flat cache — rebuilt by `rebuild_caches()`.
567 elevator_entities: Vec<EntityId>,
568 /// Derived flat cache — rebuilt by `rebuild_caches()`.
569 stop_entities: Vec<EntityId>,
570}
571
572impl ElevatorGroup {
573 /// Create a new group with the given lines. Caches are built automatically.
574 /// Defaults: [`HallCallMode::Classic`], `ack_latency_ticks = 0`.
575 #[must_use]
576 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
577 let mut group = Self {
578 id,
579 name,
580 lines,
581 hall_call_mode: HallCallMode::default(),
582 ack_latency_ticks: 0,
583 elevator_entities: Vec::new(),
584 stop_entities: Vec::new(),
585 };
586 group.rebuild_caches();
587 group
588 }
589
590 /// Override the hall call mode for this group.
591 #[must_use]
592 pub const fn with_hall_call_mode(mut self, mode: HallCallMode) -> Self {
593 self.hall_call_mode = mode;
594 self
595 }
596
597 /// Override the ack latency for this group.
598 #[must_use]
599 pub const fn with_ack_latency_ticks(mut self, ticks: u32) -> Self {
600 self.ack_latency_ticks = ticks;
601 self
602 }
603
604 /// Set the hall call mode in-place (for mutation via
605 /// [`Simulation::groups_mut`](crate::sim::Simulation::groups_mut)).
606 pub const fn set_hall_call_mode(&mut self, mode: HallCallMode) {
607 self.hall_call_mode = mode;
608 }
609
610 /// Set the ack latency in-place.
611 pub const fn set_ack_latency_ticks(&mut self, ticks: u32) {
612 self.ack_latency_ticks = ticks;
613 }
614
615 /// Hall call mode for this group.
616 #[must_use]
617 pub const fn hall_call_mode(&self) -> HallCallMode {
618 self.hall_call_mode
619 }
620
621 /// Controller ack latency for this group.
622 #[must_use]
623 pub const fn ack_latency_ticks(&self) -> u32 {
624 self.ack_latency_ticks
625 }
626
627 /// Unique group identifier.
628 #[must_use]
629 pub const fn id(&self) -> GroupId {
630 self.id
631 }
632
633 /// Human-readable group name.
634 #[must_use]
635 pub fn name(&self) -> &str {
636 &self.name
637 }
638
639 /// Lines belonging to this group.
640 #[must_use]
641 pub fn lines(&self) -> &[LineInfo] {
642 &self.lines
643 }
644
645 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
646 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
647 &mut self.lines
648 }
649
650 /// Elevator entities belonging to this group (derived from lines).
651 #[must_use]
652 pub fn elevator_entities(&self) -> &[EntityId] {
653 &self.elevator_entities
654 }
655
656 /// Stop entities served by this group (derived from lines, deduplicated).
657 #[must_use]
658 pub fn stop_entities(&self) -> &[EntityId] {
659 &self.stop_entities
660 }
661
662 /// Whether this group can serve a rider on `leg`. A `Group(g)` leg
663 /// matches by group id; a `Line(l)` leg matches if `l` belongs to
664 /// this group; `Walk` never rides an elevator.
665 #[must_use]
666 pub fn accepts_leg(&self, leg: &crate::components::RouteLeg) -> bool {
667 match leg.via {
668 crate::components::TransportMode::Group(g) => g == self.id,
669 crate::components::TransportMode::Line(l) => {
670 self.lines.iter().any(|li| li.entity() == l)
671 }
672 crate::components::TransportMode::Walk => false,
673 }
674 }
675
676 /// Push a stop entity directly into the group's stop cache.
677 ///
678 /// Use when a stop belongs to the group for dispatch purposes but is
679 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
680 /// wire it into the topology graph.
681 pub(crate) fn push_stop(&mut self, stop: EntityId) {
682 if !self.stop_entities.contains(&stop) {
683 self.stop_entities.push(stop);
684 }
685 }
686
687 /// Push an elevator entity directly into the group's elevator cache
688 /// (in addition to the line it belongs to).
689 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
690 if !self.elevator_entities.contains(&elevator) {
691 self.elevator_entities.push(elevator);
692 }
693 }
694
695 /// Rebuild derived caches from lines. Call after mutating lines.
696 pub fn rebuild_caches(&mut self) {
697 self.elevator_entities = self
698 .lines
699 .iter()
700 .flat_map(|li| li.elevators.iter().copied())
701 .collect();
702 let mut stops: Vec<EntityId> = self
703 .lines
704 .iter()
705 .flat_map(|li| li.serves.iter().copied())
706 .collect();
707 stops.sort_unstable();
708 stops.dedup();
709 self.stop_entities = stops;
710 }
711}
712
713/// Look up the `serves` list for an elevator's line.
714///
715/// Walks `groups` to find the [`LineInfo`] whose entity matches the
716/// car's current `line()`. Returns `None` if the car has no line
717/// registered in any group (an inconsistent state — should be
718/// unreachable in a healthy sim).
719///
720/// Helper for callers of
721/// [`World::find_stop_at_position_in`](crate::world::World::find_stop_at_position_in)
722/// that already have group context: `find_stop_at_position(pos)` is
723/// global (any line wins) and ambiguous when two lines share a
724/// position; passing the elevator's serves list scopes the lookup to
725/// *its* line.
726///
727/// Cost: `O(groups × lines_per_group)` per call. For loops over many
728/// elevators per tick, prefer [`build_line_serves_index`] +
729/// [`elevator_line_serves_indexed`] to amortize the line walk.
730#[must_use]
731pub fn elevator_line_serves<'a>(
732 world: &World,
733 groups: &'a [ElevatorGroup],
734 elevator: EntityId,
735) -> Option<&'a [EntityId]> {
736 let line_eid = world.elevator(elevator)?.line();
737 groups
738 .iter()
739 .flat_map(ElevatorGroup::lines)
740 .find(|li| li.entity() == line_eid)
741 .map(LineInfo::serves)
742}
743
744/// Pre-built index mapping each line entity to its `serves` slice.
745/// Built once with [`build_line_serves_index`]; queried with
746/// [`elevator_line_serves_indexed`] for O(1) per-elevator lookup.
747pub type LineServesIndex<'a> = std::collections::HashMap<EntityId, &'a [EntityId]>;
748
749/// Build a [`LineServesIndex`] from the group list. O(groups × lines).
750/// Call once per substep / system and reuse across the elevator loop.
751#[must_use]
752pub fn build_line_serves_index(groups: &[ElevatorGroup]) -> LineServesIndex<'_> {
753 let mut idx: LineServesIndex<'_> = std::collections::HashMap::new();
754 for li in groups.iter().flat_map(ElevatorGroup::lines) {
755 idx.insert(li.entity(), li.serves());
756 }
757 idx
758}
759
760/// Indexed variant of [`elevator_line_serves`]. O(1) per call given
761/// a pre-built [`LineServesIndex`].
762#[must_use]
763pub fn elevator_line_serves_indexed<'a>(
764 world: &World,
765 index: &LineServesIndex<'a>,
766 elevator: EntityId,
767) -> Option<&'a [EntityId]> {
768 let line_eid = world.elevator(elevator)?.line();
769 index.get(&line_eid).copied()
770}
771
772/// Context passed to [`DispatchStrategy::rank`].
773///
774/// Bundles the per-call arguments into a single struct so future context
775/// fields can be added without breaking existing trait implementations.
776#[non_exhaustive]
777pub struct RankContext<'a> {
778 /// The elevator being evaluated.
779 pub car: EntityId,
780 /// The stop being evaluated as a candidate destination.
781 pub stop: EntityId,
782 /// The dispatch group this assignment belongs to.
783 pub group: &'a ElevatorGroup,
784 /// Demand snapshot for the current dispatch pass.
785 pub manifest: &'a DispatchManifest,
786 /// Read-only world state.
787 pub world: &'a World,
788}
789
790impl RankContext<'_> {
791 /// Position of [`car`](Self::car) along the shaft axis.
792 ///
793 /// Returns `0.0` for an entity that has no `Position` component
794 /// (which would never reach this method through normal dispatch
795 /// — `compute_assignments` filters out cars without positions
796 /// upstream — but the defensive default protects custom callers).
797 /// Derived from [`world`](Self::world) on each call: the dispatch
798 /// loop never moves elevators between rank calls, so re-deriving
799 /// is free, and skipping the duplicate field eliminates the
800 /// synchronisation risk of the old shape.
801 #[must_use]
802 pub fn car_position(&self) -> f64 {
803 self.world.position(self.car).map_or(0.0, |p| p.value)
804 }
805
806 /// Position of [`stop`](Self::stop) along the shaft axis.
807 ///
808 /// Returns `0.0` for an entity that has no `Stop` component (same
809 /// rationale as [`car_position`](Self::car_position)).
810 #[must_use]
811 pub fn stop_position(&self) -> f64 {
812 self.world.stop_position(self.stop).unwrap_or(0.0)
813 }
814}
815
816impl std::fmt::Debug for RankContext<'_> {
817 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818 f.debug_struct("RankContext")
819 .field("car", &self.car)
820 .field("car_position", &self.car_position())
821 .field("stop", &self.stop)
822 .field("stop_position", &self.stop_position())
823 .field("group", &self.group)
824 .field("manifest", &self.manifest)
825 .field("world", &"World { .. }")
826 .finish()
827 }
828}
829
830/// Pluggable dispatch algorithm.
831///
832/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
833/// pair; the dispatch system then performs an optimal assignment across
834/// the whole group, guaranteeing that no two cars are sent to the same
835/// hall call.
836///
837/// Returning `None` from `rank` excludes a pair from assignment — useful
838/// for capacity limits, direction preferences, restricted stops, or
839/// sticky commitments.
840///
841/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
842/// which returns the policy for that car (idle, park, etc.).
843pub trait DispatchStrategy: Send + Sync {
844 /// Optional hook called once per group before the assignment pass.
845 ///
846 /// Strategies that need to mutate [`World`] extension storage (e.g.
847 /// [`DestinationDispatch`] writing sticky rider → car assignments)
848 /// or pre-populate [`crate::components::DestinationQueue`] entries
849 /// override this. Default: no-op.
850 fn pre_dispatch(
851 &mut self,
852 _group: &ElevatorGroup,
853 _manifest: &DispatchManifest,
854 _world: &mut World,
855 ) {
856 }
857
858 /// Optional hook called once per candidate car, before any
859 /// [`rank`](Self::rank) calls for that car in the current pass.
860 ///
861 /// Strategies whose ranking depends on stable per-car state (e.g. the
862 /// sweep direction used by SCAN/LOOK) set that state here so later
863 /// `rank` calls see a consistent view regardless of iteration order.
864 /// The default is a no-op.
865 fn prepare_car(
866 &mut self,
867 _car: EntityId,
868 _car_position: f64,
869 _group: &ElevatorGroup,
870 _manifest: &DispatchManifest,
871 _world: &World,
872 ) {
873 }
874
875 /// Score the cost of sending `car` to `stop`. Lower is better.
876 ///
877 /// Returning `None` marks this `(car, stop)` pair as unavailable;
878 /// the assignment algorithm will never pair them. Use this for
879 /// capacity limits, wrong-direction stops, stops outside the line's
880 /// topology, or pairs already committed via a sticky assignment.
881 ///
882 /// Must return a finite, non-negative value if `Some` — infinities
883 /// and NaN can destabilize the underlying Hungarian solver.
884 ///
885 /// Takes `&self` so the assignment loop can score `(car, stop)` pairs
886 /// in any order without producing an asymmetric cost matrix. Compute
887 /// any per-car scratch in [`prepare_car`](Self::prepare_car) (which
888 /// takes `&mut self`) before this method is called.
889 fn rank(&self, ctx: &RankContext<'_>) -> Option<f64>;
890
891 /// Decide what an idle car should do when no stop was assigned to it.
892 ///
893 /// Called for each car the assignment phase could not pair with a
894 /// stop (because there were no stops, or all candidate stops had
895 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
896 fn fallback(
897 &mut self,
898 _car: EntityId,
899 _car_position: f64,
900 _group: &ElevatorGroup,
901 _manifest: &DispatchManifest,
902 _world: &World,
903 ) -> DispatchDecision {
904 DispatchDecision::Idle
905 }
906
907 /// Notify the strategy that an elevator has been removed.
908 ///
909 /// Implementations with per-elevator state (e.g. direction tracking)
910 /// should clean up here to prevent unbounded memory growth.
911 fn notify_removed(&mut self, _elevator: EntityId) {}
912
913 /// If this strategy is a known built-in variant, return it so
914 /// [`Simulation::new`](crate::sim::Simulation::new) can stamp the
915 /// correct [`BuiltinStrategy`] into the group's snapshot identity.
916 ///
917 /// Without this, legacy-topology sims constructed via
918 /// `Simulation::new(config, SomeNonScanStrategy::new())` silently
919 /// recorded `BuiltinStrategy::Scan` as their identity — so a
920 /// snapshot round-trip replaced the running strategy with Scan
921 /// and produced different dispatch decisions post-restore
922 /// (determinism regression).
923 ///
924 /// Default: `None` (unidentified — the constructor falls back to
925 /// recording [`BuiltinStrategy::Scan`], matching pre-fix behaviour
926 /// for callers that never cared about round-trip identity). Custom
927 /// strategies that DO care should override this to return
928 /// [`BuiltinStrategy::Custom`] with a stable name.
929 #[must_use]
930 fn builtin_id(&self) -> Option<BuiltinStrategy> {
931 None
932 }
933
934 /// Serialize this strategy's tunable configuration to a string
935 /// that [`restore_config`](Self::restore_config) can apply to a
936 /// freshly-instantiated instance.
937 ///
938 /// Returning `Some(..)` makes the configuration survive snapshot
939 /// round-trip: without it, [`crate::snapshot::WorldSnapshot::restore`]
940 /// instantiates each built-in via [`BuiltinStrategy::instantiate`],
941 /// which calls `::new()` with default weights — silently dropping
942 /// any tuning applied via `with_*` builder methods (e.g.
943 /// `EtdDispatch::with_delay_weight(2.5)` degrades to the default
944 /// `1.0` on the restored sim).
945 ///
946 /// Default: `None` (no configuration to save). Built-ins with
947 /// tunable weights override to return a RON-serialized copy of
948 /// themselves; strategies with transient per-pass scratch should
949 /// use `#[serde(skip)]` on those fields so the snapshot stays
950 /// compact and deterministic.
951 #[must_use]
952 fn snapshot_config(&self) -> Option<String> {
953 None
954 }
955
956 /// Restore tunable configuration from a string previously produced
957 /// by [`snapshot_config`](Self::snapshot_config) on the same
958 /// strategy variant. Called by
959 /// [`crate::snapshot::WorldSnapshot::restore`] immediately after
960 /// [`BuiltinStrategy::instantiate`] builds the default instance,
961 /// so the restore writes over the defaults.
962 ///
963 /// # Errors
964 /// Returns the underlying parse error as a `String` when the
965 /// serialized form doesn't round-trip. Default implementation
966 /// ignores the argument and returns `Ok(())` — paired with the
967 /// `None` default of `snapshot_config`, this means strategies that
968 /// don't override either method skip configuration round-trip,
969 /// matching pre-fix behaviour.
970 fn restore_config(&mut self, _serialized: &str) -> Result<(), String> {
971 Ok(())
972 }
973
974 /// Maximum candidate stops the assignment phase considers per car.
975 ///
976 /// `Some(K)` keeps only the K nearest viable pending stops per
977 /// idle car when filling the cost matrix; the rest are sentinel-
978 /// scored so the Hungarian skips them. `None` disables pruning
979 /// (full matrix). The default is `Some(50)` — generous enough to
980 /// preserve optimality on real-building loads (≤200 stops, ≤50
981 /// cars) while cutting per-cell `rank()` calls ~90× at extreme
982 /// scale (5000 stops × 500 cars). Researchers and tests asserting
983 /// global-optimal assignments can opt out via the strategy's
984 /// `with_candidate_limit(None)` builder.
985 ///
986 /// "Nearest" here means absolute axial distance
987 /// (`|car_pos - stop_pos|`) with `(distance, EntityId)` tie-break
988 /// for snapshot-determinism. Line-restriction and
989 /// [`Elevator::restricted_stops`](crate::components::Elevator::restricted_stops)
990 /// filtering happens *before* the top-K cut, so a car always
991 /// sees up to K *viable* candidates rather than K nominal ones
992 /// of which most are unreachable.
993 ///
994 /// Strategies that don't go through the Hungarian path
995 /// ([`scan::ScanDispatch`], [`look::LookDispatch`],
996 /// [`nearest_car::NearestCarDispatch`]) inherit the default but
997 /// it's a no-op for them — their per-car `rank` is independent
998 /// of matrix size.
999 #[must_use]
1000 fn candidate_limit(&self) -> Option<usize> {
1001 Some(DEFAULT_CANDIDATE_LIMIT)
1002 }
1003}
1004
1005/// Default per-car candidate limit applied by the
1006/// [`DispatchStrategy::candidate_limit`] trait default.
1007///
1008/// Strategies that build with a custom limit override the trait method
1009/// to return their stored value; opting out (`None`) disables pruning
1010/// entirely.
1011pub const DEFAULT_CANDIDATE_LIMIT: usize = 50;
1012
1013/// Pluggable strategy for repositioning idle elevators.
1014///
1015/// After the dispatch phase, elevators that remain idle (no pending
1016/// assignments) are candidates for repositioning. The strategy decides
1017/// where each idle elevator should move to improve coverage and reduce
1018/// expected response times.
1019///
1020/// Implementations receive the set of idle elevator positions and the
1021/// group's stop positions, then return a target stop for each elevator
1022/// (or `None` to leave it in place).
1023pub trait RepositionStrategy: Send + Sync {
1024 /// Decide where to reposition idle elevators.
1025 ///
1026 /// Push `(elevator_entity, target_stop_entity)` pairs into `out`.
1027 /// The buffer is cleared before each call — implementations should
1028 /// only push, never read prior contents. Elevators not pushed remain idle.
1029 fn reposition(
1030 &mut self,
1031 idle_elevators: &[(EntityId, f64)],
1032 stop_positions: &[(EntityId, f64)],
1033 group: &ElevatorGroup,
1034 world: &World,
1035 out: &mut Vec<(EntityId, EntityId)>,
1036 );
1037
1038 /// If this strategy is a known built-in variant, return it so
1039 /// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1040 /// callers don't have to pass a separate [`BuiltinReposition`] id
1041 /// that might drift from the dispatcher's actual type.
1042 ///
1043 /// Mirrors the pattern introduced for [`DispatchStrategy::builtin_id`]
1044 /// in #410: the runtime impl identifies itself so the snapshot
1045 /// identity always matches the executing behaviour, instead of
1046 /// depending on the caller to keep two parameters consistent.
1047 /// Default `None` — custom strategies should override to return
1048 /// [`BuiltinReposition::Custom`] with a stable name for snapshot
1049 /// fidelity.
1050 #[must_use]
1051 fn builtin_id(&self) -> Option<BuiltinReposition> {
1052 None
1053 }
1054
1055 /// Minimum [`ArrivalLog`](crate::arrival_log::ArrivalLog) retention
1056 /// (in ticks) the strategy needs to function. Strategies that read
1057 /// the log directly with a custom rolling window must override this
1058 /// so [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
1059 /// can widen
1060 /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1061 /// to keep the data alive long enough for the query.
1062 ///
1063 /// Default `0` — strategies that don't read the arrival log (or that
1064 /// only consume it through [`DispatchManifest::arrivals_at`], which
1065 /// already tracks retention) impose no requirement.
1066 #[must_use]
1067 fn min_arrival_log_window(&self) -> u64 {
1068 0
1069 }
1070}
1071
1072/// Serializable identifier for built-in repositioning strategies.
1073///
1074/// Used in config and snapshots to restore the correct strategy.
1075#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1076#[non_exhaustive]
1077pub enum BuiltinReposition {
1078 /// Distribute idle elevators evenly across stops.
1079 SpreadEvenly,
1080 /// Return idle elevators to a configured home stop.
1081 ReturnToLobby,
1082 /// Position near stops with historically high demand.
1083 DemandWeighted,
1084 /// Keep idle elevators where they are (no-op).
1085 NearestIdle,
1086 /// Pre-position cars near stops with the highest recent arrival rate.
1087 PredictiveParking,
1088 /// Mode-gated: picks between `ReturnToLobby` / `PredictiveParking`
1089 /// based on the current `TrafficDetector` mode.
1090 Adaptive,
1091 /// Custom strategy identified by name.
1092 Custom(String),
1093}
1094
1095impl std::fmt::Display for BuiltinReposition {
1096 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1097 match self {
1098 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
1099 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
1100 Self::DemandWeighted => write!(f, "DemandWeighted"),
1101 Self::NearestIdle => write!(f, "NearestIdle"),
1102 Self::PredictiveParking => write!(f, "PredictiveParking"),
1103 Self::Adaptive => write!(f, "Adaptive"),
1104 Self::Custom(name) => write!(f, "Custom({name})"),
1105 }
1106 }
1107}
1108
1109impl BuiltinReposition {
1110 /// Instantiate the reposition strategy for this variant.
1111 ///
1112 /// Returns `None` for `Custom` — the game must provide those via
1113 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
1114 #[must_use]
1115 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
1116 match self {
1117 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
1118 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
1119 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
1120 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
1121 Self::PredictiveParking => Some(Box::new(reposition::PredictiveParking::new())),
1122 Self::Adaptive => Some(Box::new(reposition::AdaptiveParking::new())),
1123 Self::Custom(_) => None,
1124 }
1125 }
1126}