Skip to main content

elevator_core/dispatch/
rsr.rs

1//! Relative System Response (RSR) dispatch — a composite additive
2//! cost stack.
3//!
4//! Inspired by the Otis patent lineage (Bittar US5024295A, US5146053A)
5//! and the Barney–dos Santos CGC framework. Unlike those proprietary
6//! systems, this implementation is an educational model, not a
7//! faithful reproduction of any vendor's scoring.
8//!
9//! Shape: `rank = eta_weight · travel_time + Σ penalties − Σ bonuses`.
10//! All terms are additive scalars, so they compose cleanly with the
11//! library's Kuhn–Munkres assignment. Defaults are tuned so the stack
12//! reduces to the nearest-car baseline when every weight is zero.
13//!
14//! What this deliberately leaves out: online weight tuning, fuzzy
15//! inference, and stickiness state. Those belong above the trait, not
16//! inside a strategy.
17
18use crate::components::{CarCall, ElevatorPhase};
19use crate::traffic_detector::{TrafficDetector, TrafficMode};
20
21use super::{DispatchStrategy, RankContext, pair_is_useful};
22
23/// Look up the current [`TrafficMode`] from `ctx.world` and return the
24/// scaling factor to apply to the wrong-direction penalty.
25///
26/// Returns `multiplier` when the mode is `UpPeak` or `DownPeak`, else
27/// `1.0`. Also returns `1.0` when the detector resource is missing —
28/// keeping the strategy functional in tests that skip `Simulation::new`.
29fn peak_scaling(ctx: &RankContext<'_>, multiplier: f64) -> f64 {
30    let mode = ctx
31        .world
32        .resource::<TrafficDetector>()
33        .map_or(TrafficMode::Idle, TrafficDetector::current_mode);
34    match mode {
35        TrafficMode::UpPeak | TrafficMode::DownPeak => multiplier,
36        _ => 1.0,
37    }
38}
39
40/// Additive RSR-style cost stack. Lower scores win the Hungarian
41/// assignment.
42///
43/// See module docs for the cost shape. All weights default to `0.0`
44/// except `eta_weight` (1.0), giving a baseline that mirrors
45/// [`NearestCarDispatch`](super::NearestCarDispatch) until terms are
46/// opted in.
47///
48/// # Weight invariants
49///
50/// Every weight field must be **finite and non-negative**. The
51/// `with_*` builder methods enforce this with `assert!`; direct field
52/// mutation bypasses the check and is a caller responsibility. A `NaN` weight propagates through the multiply-add
53/// chain and silently collapses every pair's cost to zero (Rust's
54/// `NaN.max(0.0) == 0.0`), producing an arbitrary but type-valid
55/// assignment from the Hungarian solver — a hard bug to diagnose.
56#[derive(serde::Serialize, serde::Deserialize)]
57pub struct RsrDispatch {
58    /// Weight on `travel_time = distance / max_speed` (seconds).
59    /// Default `1.0`; raising it shifts the blend toward travel time.
60    pub eta_weight: f64,
61    /// Constant added when the candidate stop lies opposite the
62    /// car's committed travel direction.
63    ///
64    /// Default `0.0`; the Otis RSR lineage uses a large value so any
65    /// right-direction candidate outranks any wrong-direction one.
66    /// Ignored for cars in [`ElevatorPhase::Idle`] or stopped phases,
67    /// since an idle car has no committed direction to be opposite to.
68    pub wrong_direction_penalty: f64,
69    /// Bonus subtracted when the candidate stop is already a car-call
70    /// inside this car.
71    ///
72    /// Merges the new pickup with an existing dropoff instead of
73    /// spawning an unrelated trip. Default `0.0`. Read from
74    /// [`DispatchManifest::car_calls_for`](super::DispatchManifest::car_calls_for).
75    pub coincident_car_call_bonus: f64,
76    /// Coefficient on a smooth load-fraction penalty
77    /// (`load_penalty_coeff · load_ratio`).
78    ///
79    /// Fires for partially loaded cars below the `bypass_load_*_pct`
80    /// threshold enforced by [`pair_is_useful`];
81    /// lets you prefer emptier cars for new pickups without an on/off cliff.
82    /// Default `0.0`.
83    pub load_penalty_coeff: f64,
84    /// Multiplier applied to `wrong_direction_penalty` when the
85    /// [`TrafficDetector`] classifies the current tick as
86    /// [`TrafficMode::UpPeak`] or [`TrafficMode::DownPeak`].
87    ///
88    /// Default `1.0` (mode-agnostic — behaviour identical to pre-peak
89    /// tuning). Raising it strengthens directional commitment during
90    /// peaks where a car carrying a lobby-bound load shouldn't be
91    /// pulled backwards to grab a new pickup. Off-peak periods keep
92    /// the unscaled penalty, leaving inter-floor assignments free
93    /// to reverse cheaply.
94    ///
95    /// Silently reduces to `1.0` when no `TrafficDetector` resource
96    /// is installed — tests and custom sims that bypass the auto-install
97    /// stay unaffected.
98    pub peak_direction_multiplier: f64,
99    /// Coefficient on the linear waiting-age fairness bonus. Each
100    /// candidate stop's rank is reduced by `age_linear_weight · Σ
101    /// wait_ticks` across waiting riders at the stop, so stops hosting
102    /// older calls win ties without the quadratic blow-up a squared-
103    /// wait term would have.
104    ///
105    /// Default `0.0` at [`new`](Self::new) / serde round-trip (keeps
106    /// single-term mutant tests valid and snapshots backward-compatible);
107    /// [`tuned`](Self::tuned) / [`default`](Self::default) ship `0.002`,
108    /// lighter than ETD's 0.005 because RSR's wrong-direction penalty
109    /// and `pair_is_useful` path guard already handle two starvation
110    /// patterns that ETD needed the full-strength term for. See the
111    /// inline comment at `tuned()` for the harness numbers behind the
112    /// calibration.
113    ///
114    /// Mirrors the Lim 1983 / Barney–dos Santos 1985 CGC lineage
115    /// already used in `EtdDispatch::age_linear_weight`; composes
116    /// additively with the other penalty-stack terms.
117    #[serde(default)]
118    pub age_linear_weight: f64,
119    /// Maximum candidate stops to consider per car when filling the
120    /// assignment cost matrix. Defaults to `Some(50)`; see
121    /// [`DispatchStrategy::candidate_limit`] for the rationale.
122    #[serde(default = "default_candidate_limit")]
123    pub candidate_limit: Option<usize>,
124}
125
126/// Serde default for [`RsrDispatch::candidate_limit`] when restoring
127/// from a pre-pruning snapshot. Matches
128/// [`super::DEFAULT_CANDIDATE_LIMIT`].
129#[allow(clippy::unnecessary_wraps)] // serde default needs Option<usize>, not usize
130const fn default_candidate_limit() -> Option<usize> {
131    Some(super::DEFAULT_CANDIDATE_LIMIT)
132}
133
134impl RsrDispatch {
135    /// Create a new `RsrDispatch` with the baseline weights
136    /// (`eta_weight = 1.0`, all penalties/bonuses disabled).
137    ///
138    /// This is the **additive-composition baseline** — every penalty
139    /// and bonus is zero, so the rank reduces to
140    /// [`NearestCarDispatch`](super::NearestCarDispatch) on distance
141    /// alone. Useful for tests that want to measure a single term in
142    /// isolation (`RsrDispatch::new().with_wrong_direction_penalty(…)`).
143    ///
144    /// For the opinionated "out-of-the-box RSR" configuration used by
145    /// [`BuiltinStrategy::Rsr`](super::BuiltinStrategy::Rsr) and the
146    /// playground, use [`RsrDispatch::default`] instead. `Default` ships
147    /// with the full penalty stack turned on; `new()` is the empty
148    /// canvas you build on top of.
149    #[must_use]
150    pub const fn new() -> Self {
151        Self {
152            eta_weight: 1.0,
153            wrong_direction_penalty: 0.0,
154            coincident_car_call_bonus: 0.0,
155            load_penalty_coeff: 0.0,
156            peak_direction_multiplier: 1.0,
157            age_linear_weight: 0.0,
158            candidate_limit: Some(super::DEFAULT_CANDIDATE_LIMIT),
159        }
160    }
161
162    /// Return the opinionated tuned configuration — equivalent to
163    /// [`Default::default`] but usable in `const` contexts.
164    ///
165    /// See [`RsrDispatch::default`] for the rationale behind each
166    /// weight. The tuned stack ships with every penalty/bonus turned
167    /// on so picking RSR out of the box is strictly richer than
168    /// `NearestCar`, not identical to it.
169    #[must_use]
170    pub const fn tuned() -> Self {
171        Self {
172            eta_weight: 1.0,
173            // Chosen ≈ one shaft-length travel time on a typical 20-stop
174            // commercial bank (≈15s), so a backward pickup costs as much
175            // as the trip to serve it. Large enough to dominate the ETA
176            // term for a close-but-wrong-direction candidate; small
177            // enough that off-peak inter-floor reversals still flip when
178            // the demand strongly favours them.
179            wrong_direction_penalty: 15.0,
180            // Small merge bonus — prefer a car with a matching car-call
181            // over spawning a new trip, but not so large it overrides a
182            // much closer empty car.
183            coincident_car_call_bonus: 5.0,
184            // Light load-balancing — prefer empty cars for new work
185            // when cars are otherwise tied.
186            load_penalty_coeff: 3.0,
187            // Strong directional commitment during up-peak / down-peak
188            // (lobby-bound loads shouldn't reverse for new pickups).
189            // Off-peak stays unscaled for cheap inter-floor reversals.
190            peak_direction_multiplier: 2.0,
191            // Linear waiting-age fairness. Tuned at 0.002 against the
192            // `playground_audit` harness — lighter than ETD's 0.005
193            // default because RSR's wrong-direction penalty and
194            // `pair_is_useful` path guard already handle two of the
195            // starvation patterns ETD needed the full-strength term
196            // for. At 0.002 on the audit skyscraper (2 cars, heavy
197            // peak), RSR beats SCAN across delivered / avg_wait /
198            // max_wait; at 0.005 the same scenario improves further
199            // but bleeds a few extra abandonments out of the
200            // single-car office run as the age bonus pulls the lone
201            // car off fresh demand faster than it can cycle. 0.002
202            // is the tighter balance.
203            age_linear_weight: 0.002,
204            candidate_limit: Some(super::DEFAULT_CANDIDATE_LIMIT),
205        }
206    }
207
208    /// Set the wrong-direction penalty.
209    ///
210    /// # Panics
211    /// Panics on non-finite or negative weights — a negative penalty
212    /// would invert the direction ordering, silently preferring
213    /// wrong-direction candidates.
214    #[must_use]
215    pub fn with_wrong_direction_penalty(mut self, weight: f64) -> Self {
216        assert!(
217            weight.is_finite() && weight >= 0.0,
218            "wrong_direction_penalty must be finite and non-negative, got {weight}"
219        );
220        self.wrong_direction_penalty = weight;
221        self
222    }
223
224    /// Set the coincident-car-call bonus.
225    ///
226    /// # Panics
227    /// Panics on non-finite or negative weights — the bonus is
228    /// subtracted, so a negative value would become a penalty.
229    #[must_use]
230    pub fn with_coincident_car_call_bonus(mut self, weight: f64) -> Self {
231        assert!(
232            weight.is_finite() && weight >= 0.0,
233            "coincident_car_call_bonus must be finite and non-negative, got {weight}"
234        );
235        self.coincident_car_call_bonus = weight;
236        self
237    }
238
239    /// Set the load-penalty coefficient.
240    ///
241    /// # Panics
242    /// Panics on non-finite or negative weights.
243    #[must_use]
244    pub fn with_load_penalty_coeff(mut self, weight: f64) -> Self {
245        assert!(
246            weight.is_finite() && weight >= 0.0,
247            "load_penalty_coeff must be finite and non-negative, got {weight}"
248        );
249        self.load_penalty_coeff = weight;
250        self
251    }
252
253    /// Set the ETA weight.
254    ///
255    /// # Panics
256    /// Panics on non-finite or negative weights. Zero is allowed and
257    /// reduces the strategy to penalty/bonus tiebreaking alone.
258    #[must_use]
259    pub fn with_eta_weight(mut self, weight: f64) -> Self {
260        assert!(
261            weight.is_finite() && weight >= 0.0,
262            "eta_weight must be finite and non-negative, got {weight}"
263        );
264        self.eta_weight = weight;
265        self
266    }
267
268    /// Set the linear waiting-age fairness weight. Composes additively
269    /// with the other penalty/bonus terms; `0.0` disables the term.
270    ///
271    /// # Panics
272    /// Panics on non-finite or negative weights. A `NaN` weight would
273    /// propagate through `mul_add` and silently collapse the stack;
274    /// a negative weight would invert the fairness ordering.
275    #[must_use]
276    pub fn with_age_linear_weight(mut self, weight: f64) -> Self {
277        assert!(
278            weight.is_finite() && weight >= 0.0,
279            "age_linear_weight must be finite and non-negative, got {weight}"
280        );
281        self.age_linear_weight = weight;
282        self
283    }
284
285    /// Set the peak-direction multiplier.
286    ///
287    /// # Panics
288    /// Panics on non-finite or sub-1.0 values. A multiplier below `1.0`
289    /// would *weaken* the direction penalty during peaks (the opposite
290    /// of the intent) — explicitly disallowed so a typo doesn't silently
291    /// invert the tuning.
292    #[must_use]
293    pub fn with_peak_direction_multiplier(mut self, factor: f64) -> Self {
294        assert!(
295            factor.is_finite() && factor >= 1.0,
296            "peak_direction_multiplier must be finite and ≥ 1.0, got {factor}"
297        );
298        self.peak_direction_multiplier = factor;
299        self
300    }
301
302    /// Set the per-car candidate limit for the assignment cost matrix.
303    /// `None` disables pruning entirely; defaults to `Some(50)`. See
304    /// [`DispatchStrategy::candidate_limit`] for the rationale.
305    #[must_use]
306    pub const fn with_candidate_limit(mut self, limit: Option<usize>) -> Self {
307        self.candidate_limit = limit;
308        self
309    }
310}
311
312impl Default for RsrDispatch {
313    /// The opinionated "pick RSR from the dropdown" configuration.
314    ///
315    /// Defaults to [`RsrDispatch::tuned`] — every penalty and bonus
316    /// turned on with values calibrated to a 20-stop commercial bank.
317    /// Before this default was tuned, `RsrDispatch::default()`
318    /// reduced to the raw [`NearestCarDispatch`](super::NearestCarDispatch)
319    /// baseline: picking "RSR" in the playground produced worse
320    /// behaviour than picking "Nearest Car" (no direction discipline,
321    /// no load balancing, no car-call merging). The tuned default
322    /// fixes that without making any term mandatory — consumers
323    /// wanting the zero baseline can still call
324    /// [`RsrDispatch::new`].
325    fn default() -> Self {
326        Self::tuned()
327    }
328}
329
330impl DispatchStrategy for RsrDispatch {
331    fn rank(&self, ctx: &RankContext<'_>) -> Option<f64> {
332        // `pair_is_useful(ctx, true)` enables the aboard-rider path
333        // guard on top of the servability check. Without it, a loaded
334        // RSR car gets pulled off the path to its aboard riders'
335        // destinations by closer pickups — the same "never reaches
336        // the passenger's desired stop" loop that NearestCar
337        // specifically fixes. RSR's `wrong_direction_penalty` can
338        // mitigate this when configured, but the guard is a
339        // correctness floor independent of tuning.
340        if !pair_is_useful(ctx, true) {
341            return None;
342        }
343        let car = ctx.world.elevator(ctx.car)?;
344
345        // ETA — travel time to the candidate stop.
346        let distance = (ctx.car_position() - ctx.stop_position()).abs();
347        let max_speed = car.max_speed.value();
348        if max_speed <= 0.0 {
349            return None;
350        }
351        let travel_time = distance / max_speed;
352        let mut cost = self.eta_weight * travel_time;
353
354        // Wrong-direction penalty. Only applies when the car has a
355        // committed direction (not Idle / Stopped) — an idle car can
356        // accept any candidate without "reversing" anything.
357        if self.wrong_direction_penalty > 0.0
358            && let Some(target) = car.phase.moving_target()
359            && let Some(target_pos) = ctx.world.stop_position(target)
360        {
361            let car_going_up = target_pos > ctx.car_position();
362            let car_going_down = target_pos < ctx.car_position();
363            let cand_above = ctx.stop_position() > ctx.car_position();
364            let cand_below = ctx.stop_position() < ctx.car_position();
365            if (car_going_up && cand_below) || (car_going_down && cand_above) {
366                // During up-peak/down-peak the directional invariant
367                // is load-bearing (a committed car shouldn't reverse
368                // to grab a new pickup), so scale the penalty up.
369                // Off-peak, the base value still rules — inter-floor
370                // traffic wants cheap reversals.
371                let scaled = self.wrong_direction_penalty
372                    * peak_scaling(ctx, self.peak_direction_multiplier);
373                cost += scaled;
374            }
375        }
376
377        // Coincident-car-call bonus — the candidate stop is already a
378        // committed dropoff for this car.
379        if self.coincident_car_call_bonus > 0.0
380            && ctx
381                .manifest
382                .car_calls_for(ctx.car)
383                .iter()
384                .any(|c: &CarCall| c.floor == ctx.stop)
385        {
386            cost -= self.coincident_car_call_bonus;
387        }
388
389        // Smooth load-fraction penalty. `pair_is_useful` has already
390        // filtered over-capacity and bypass-threshold cases; this term
391        // shapes preference among the survivors so emptier cars win
392        // pickups when all else is equal. Idle cars contribute zero.
393        if self.load_penalty_coeff > 0.0 && car.phase() != ElevatorPhase::Idle {
394            let capacity = car.weight_capacity().value();
395            if capacity > 0.0 {
396                let load_ratio = (car.current_load().value() / capacity).clamp(0.0, 1.0);
397                cost += self.load_penalty_coeff * load_ratio;
398            }
399        }
400
401        // Linear waiting-age fairness bonus. Pulls stale calls forward
402        // without the quadratic blow-up a squared-wait term would have;
403        // prevents fresh lobby-side demand from indefinitely preempting
404        // older upper-floor waiters. Mirrors ETD's `age_linear_weight`.
405        if self.age_linear_weight > 0.0 {
406            let wait_sum = super::wait_ticks_sum(ctx.manifest.waiting_riders_at(ctx.stop));
407            cost = super::apply_fairness_bonus(cost, self.age_linear_weight, wait_sum);
408        }
409
410        // Clamp the running cost to the Hungarian's non-negative floor.
411        // Two paths reach here without a guaranteed prior clamp:
412        //   1. `age_linear_weight == 0.0` skips the fairness branch
413        //      entirely, leaving the coincident-car-call subtraction
414        //      unclamped.
415        //   2. Even when fairness runs, the coincident-call subtraction
416        //      happens before it on a small-eta pairing.
417        // Either way, this trailing clamp is the only floor the
418        // assigner sees.
419        let cost = cost.max(0.0);
420        if cost.is_finite() { Some(cost) } else { None }
421    }
422
423    fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
424        Some(super::BuiltinStrategy::Rsr)
425    }
426
427    fn candidate_limit(&self) -> Option<usize> {
428        self.candidate_limit
429    }
430
431    fn snapshot_config(&self) -> Option<String> {
432        ron::to_string(self).ok()
433    }
434
435    fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
436        let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
437        *self = restored;
438        Ok(())
439    }
440}