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_can_do_work`](super::pair_can_do_work);
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}
120
121impl RsrDispatch {
122 /// Create a new `RsrDispatch` with the baseline weights
123 /// (`eta_weight = 1.0`, all penalties/bonuses disabled).
124 ///
125 /// This is the **additive-composition baseline** — every penalty
126 /// and bonus is zero, so the rank reduces to
127 /// [`NearestCarDispatch`](super::NearestCarDispatch) on distance
128 /// alone. Useful for tests that want to measure a single term in
129 /// isolation (`RsrDispatch::new().with_wrong_direction_penalty(…)`).
130 ///
131 /// For the opinionated "out-of-the-box RSR" configuration used by
132 /// [`BuiltinStrategy::Rsr`](super::BuiltinStrategy::Rsr) and the
133 /// playground, use [`RsrDispatch::default`] instead. `Default` ships
134 /// with the full penalty stack turned on; `new()` is the empty
135 /// canvas you build on top of.
136 #[must_use]
137 pub const fn new() -> Self {
138 Self {
139 eta_weight: 1.0,
140 wrong_direction_penalty: 0.0,
141 coincident_car_call_bonus: 0.0,
142 load_penalty_coeff: 0.0,
143 peak_direction_multiplier: 1.0,
144 age_linear_weight: 0.0,
145 }
146 }
147
148 /// Return the opinionated tuned configuration — equivalent to
149 /// [`Default::default`] but usable in `const` contexts.
150 ///
151 /// See [`RsrDispatch::default`] for the rationale behind each
152 /// weight. The tuned stack ships with every penalty/bonus turned
153 /// on so picking RSR out of the box is strictly richer than
154 /// `NearestCar`, not identical to it.
155 #[must_use]
156 pub const fn tuned() -> Self {
157 Self {
158 eta_weight: 1.0,
159 // Chosen ≈ one shaft-length travel time on a typical 20-stop
160 // commercial bank (≈15s), so a backward pickup costs as much
161 // as the trip to serve it. Large enough to dominate the ETA
162 // term for a close-but-wrong-direction candidate; small
163 // enough that off-peak inter-floor reversals still flip when
164 // the demand strongly favours them.
165 wrong_direction_penalty: 15.0,
166 // Small merge bonus — prefer a car with a matching car-call
167 // over spawning a new trip, but not so large it overrides a
168 // much closer empty car.
169 coincident_car_call_bonus: 5.0,
170 // Light load-balancing — prefer empty cars for new work
171 // when cars are otherwise tied.
172 load_penalty_coeff: 3.0,
173 // Strong directional commitment during up-peak / down-peak
174 // (lobby-bound loads shouldn't reverse for new pickups).
175 // Off-peak stays unscaled for cheap inter-floor reversals.
176 peak_direction_multiplier: 2.0,
177 // Linear waiting-age fairness. Tuned at 0.002 against the
178 // `playground_audit` harness — lighter than ETD's 0.005
179 // default because RSR's wrong-direction penalty and
180 // `pair_is_useful` path guard already handle two of the
181 // starvation patterns ETD needed the full-strength term
182 // for. At 0.002 on the audit skyscraper (2 cars, heavy
183 // peak), RSR beats SCAN across delivered / avg_wait /
184 // max_wait; at 0.005 the same scenario improves further
185 // but bleeds a few extra abandonments out of the
186 // single-car office run as the age bonus pulls the lone
187 // car off fresh demand faster than it can cycle. 0.002
188 // is the tighter balance.
189 age_linear_weight: 0.002,
190 }
191 }
192
193 /// Set the wrong-direction penalty.
194 ///
195 /// # Panics
196 /// Panics on non-finite or negative weights — a negative penalty
197 /// would invert the direction ordering, silently preferring
198 /// wrong-direction candidates.
199 #[must_use]
200 pub fn with_wrong_direction_penalty(mut self, weight: f64) -> Self {
201 assert!(
202 weight.is_finite() && weight >= 0.0,
203 "wrong_direction_penalty must be finite and non-negative, got {weight}"
204 );
205 self.wrong_direction_penalty = weight;
206 self
207 }
208
209 /// Set the coincident-car-call bonus.
210 ///
211 /// # Panics
212 /// Panics on non-finite or negative weights — the bonus is
213 /// subtracted, so a negative value would become a penalty.
214 #[must_use]
215 pub fn with_coincident_car_call_bonus(mut self, weight: f64) -> Self {
216 assert!(
217 weight.is_finite() && weight >= 0.0,
218 "coincident_car_call_bonus must be finite and non-negative, got {weight}"
219 );
220 self.coincident_car_call_bonus = weight;
221 self
222 }
223
224 /// Set the load-penalty coefficient.
225 ///
226 /// # Panics
227 /// Panics on non-finite or negative weights.
228 #[must_use]
229 pub fn with_load_penalty_coeff(mut self, weight: f64) -> Self {
230 assert!(
231 weight.is_finite() && weight >= 0.0,
232 "load_penalty_coeff must be finite and non-negative, got {weight}"
233 );
234 self.load_penalty_coeff = weight;
235 self
236 }
237
238 /// Set the ETA weight.
239 ///
240 /// # Panics
241 /// Panics on non-finite or negative weights. Zero is allowed and
242 /// reduces the strategy to penalty/bonus tiebreaking alone.
243 #[must_use]
244 pub fn with_eta_weight(mut self, weight: f64) -> Self {
245 assert!(
246 weight.is_finite() && weight >= 0.0,
247 "eta_weight must be finite and non-negative, got {weight}"
248 );
249 self.eta_weight = weight;
250 self
251 }
252
253 /// Set the linear waiting-age fairness weight. Composes additively
254 /// with the other penalty/bonus terms; `0.0` disables the term.
255 ///
256 /// # Panics
257 /// Panics on non-finite or negative weights. A `NaN` weight would
258 /// propagate through `mul_add` and silently collapse the stack;
259 /// a negative weight would invert the fairness ordering.
260 #[must_use]
261 pub fn with_age_linear_weight(mut self, weight: f64) -> Self {
262 assert!(
263 weight.is_finite() && weight >= 0.0,
264 "age_linear_weight must be finite and non-negative, got {weight}"
265 );
266 self.age_linear_weight = weight;
267 self
268 }
269
270 /// Set the peak-direction multiplier.
271 ///
272 /// # Panics
273 /// Panics on non-finite or sub-1.0 values. A multiplier below `1.0`
274 /// would *weaken* the direction penalty during peaks (the opposite
275 /// of the intent) — explicitly disallowed so a typo doesn't silently
276 /// invert the tuning.
277 #[must_use]
278 pub fn with_peak_direction_multiplier(mut self, factor: f64) -> Self {
279 assert!(
280 factor.is_finite() && factor >= 1.0,
281 "peak_direction_multiplier must be finite and ≥ 1.0, got {factor}"
282 );
283 self.peak_direction_multiplier = factor;
284 self
285 }
286}
287
288impl Default for RsrDispatch {
289 /// The opinionated "pick RSR from the dropdown" configuration.
290 ///
291 /// Defaults to [`RsrDispatch::tuned`] — every penalty and bonus
292 /// turned on with values calibrated to a 20-stop commercial bank.
293 /// Before this default was tuned, `RsrDispatch::default()`
294 /// reduced to the raw [`NearestCarDispatch`](super::NearestCarDispatch)
295 /// baseline: picking "RSR" in the playground produced worse
296 /// behaviour than picking "Nearest Car" (no direction discipline,
297 /// no load balancing, no car-call merging). The tuned default
298 /// fixes that without making any term mandatory — consumers
299 /// wanting the zero baseline can still call
300 /// [`RsrDispatch::new`].
301 fn default() -> Self {
302 Self::tuned()
303 }
304}
305
306impl DispatchStrategy for RsrDispatch {
307 fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
308 // `pair_is_useful` subsumes `pair_can_do_work` and adds the
309 // aboard-rider path guard. Without it, a loaded RSR car gets
310 // pulled off the path to its aboard riders' destinations by
311 // closer pickups — the same "never reaches the passenger's
312 // desired stop" loop that NearestCar specifically fixes. RSR's
313 // `wrong_direction_penalty` can mitigate this when configured,
314 // but the guard is a correctness floor independent of tuning.
315 if !pair_is_useful(ctx) {
316 return None;
317 }
318 let car = ctx.world.elevator(ctx.car)?;
319
320 // ETA — travel time to the candidate stop.
321 let distance = (ctx.car_position - ctx.stop_position).abs();
322 let max_speed = car.max_speed.value();
323 if max_speed <= 0.0 {
324 return None;
325 }
326 let travel_time = distance / max_speed;
327 let mut cost = self.eta_weight * travel_time;
328
329 // Wrong-direction penalty. Only applies when the car has a
330 // committed direction (not Idle / Stopped) — an idle car can
331 // accept any candidate without "reversing" anything.
332 if self.wrong_direction_penalty > 0.0
333 && let Some(target) = car.phase.moving_target()
334 && let Some(target_pos) = ctx.world.stop_position(target)
335 {
336 let car_going_up = target_pos > ctx.car_position;
337 let car_going_down = target_pos < ctx.car_position;
338 let cand_above = ctx.stop_position > ctx.car_position;
339 let cand_below = ctx.stop_position < ctx.car_position;
340 if (car_going_up && cand_below) || (car_going_down && cand_above) {
341 // During up-peak/down-peak the directional invariant
342 // is load-bearing (a committed car shouldn't reverse
343 // to grab a new pickup), so scale the penalty up.
344 // Off-peak, the base value still rules — inter-floor
345 // traffic wants cheap reversals.
346 let scaled = self.wrong_direction_penalty
347 * peak_scaling(ctx, self.peak_direction_multiplier);
348 cost += scaled;
349 }
350 }
351
352 // Coincident-car-call bonus — the candidate stop is already a
353 // committed dropoff for this car.
354 if self.coincident_car_call_bonus > 0.0
355 && ctx
356 .manifest
357 .car_calls_for(ctx.car)
358 .iter()
359 .any(|c: &CarCall| c.floor == ctx.stop)
360 {
361 cost -= self.coincident_car_call_bonus;
362 }
363
364 // Smooth load-fraction penalty. `pair_can_do_work` has already
365 // filtered over-capacity and bypass-threshold cases; this term
366 // shapes preference among the survivors so emptier cars win
367 // pickups when all else is equal. Idle cars contribute zero.
368 if self.load_penalty_coeff > 0.0 && car.phase() != ElevatorPhase::Idle {
369 let capacity = car.weight_capacity().value();
370 if capacity > 0.0 {
371 let load_ratio = (car.current_load().value() / capacity).clamp(0.0, 1.0);
372 cost += self.load_penalty_coeff * load_ratio;
373 }
374 }
375
376 // Linear waiting-age fairness bonus. Pulls stale calls forward
377 // without the quadratic blow-up a squared-wait term would have;
378 // prevents fresh lobby-side demand from indefinitely preempting
379 // older upper-floor waiters. Mirrors ETD's `age_linear_weight`.
380 if self.age_linear_weight > 0.0 {
381 let wait_sum: f64 = ctx
382 .manifest
383 .waiting_riders_at(ctx.stop)
384 .iter()
385 .map(|r| r.wait_ticks as f64)
386 .sum();
387 cost = crate::fp::fma(self.age_linear_weight, -wait_sum, cost);
388 }
389
390 let cost = cost.max(0.0);
391 if cost.is_finite() { Some(cost) } else { None }
392 }
393
394 fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
395 Some(super::BuiltinStrategy::Rsr)
396 }
397
398 fn snapshot_config(&self) -> Option<String> {
399 ron::to_string(self).ok()
400 }
401
402 fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
403 let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
404 *self = restored;
405 Ok(())
406 }
407}