elevator-core 16.0.0

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Relative System Response (RSR) dispatch — a composite additive
//! cost stack.
//!
//! Inspired by the Otis patent lineage (Bittar US5024295A, US5146053A)
//! and the Barney–dos Santos CGC framework. Unlike those proprietary
//! systems, this implementation is an educational model, not a
//! faithful reproduction of any vendor's scoring.
//!
//! Shape: `rank = eta_weight · travel_time + Σ penalties − Σ bonuses`.
//! All terms are additive scalars, so they compose cleanly with the
//! library's Kuhn–Munkres assignment. Defaults are tuned so the stack
//! reduces to the nearest-car baseline when every weight is zero.
//!
//! What this deliberately leaves out: online weight tuning, fuzzy
//! inference, and stickiness state. Those belong above the trait, not
//! inside a strategy.

use crate::components::{CarCall, ElevatorPhase};
use crate::traffic_detector::{TrafficDetector, TrafficMode};

use super::{DispatchStrategy, RankContext, pair_is_useful};

/// Look up the current [`TrafficMode`] from `ctx.world` and return the
/// scaling factor to apply to the wrong-direction penalty.
///
/// Returns `multiplier` when the mode is `UpPeak` or `DownPeak`, else
/// `1.0`. Also returns `1.0` when the detector resource is missing —
/// keeping the strategy functional in tests that skip `Simulation::new`.
fn peak_scaling(ctx: &RankContext<'_>, multiplier: f64) -> f64 {
    let mode = ctx
        .world
        .resource::<TrafficDetector>()
        .map_or(TrafficMode::Idle, TrafficDetector::current_mode);
    match mode {
        TrafficMode::UpPeak | TrafficMode::DownPeak => multiplier,
        _ => 1.0,
    }
}

/// Additive RSR-style cost stack. Lower scores win the Hungarian
/// assignment.
///
/// See module docs for the cost shape. All weights default to `0.0`
/// except `eta_weight` (1.0), giving a baseline that mirrors
/// [`NearestCarDispatch`](super::NearestCarDispatch) until terms are
/// opted in.
///
/// # Weight invariants
///
/// Every weight field must be **finite and non-negative**. The
/// `with_*` builder methods enforce this with `assert!`; direct field
/// mutation bypasses the check and is a caller responsibility. A `NaN` weight propagates through the multiply-add
/// chain and silently collapses every pair's cost to zero (Rust's
/// `NaN.max(0.0) == 0.0`), producing an arbitrary but type-valid
/// assignment from the Hungarian solver — a hard bug to diagnose.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RsrDispatch {
    /// Weight on `travel_time = distance / max_speed` (seconds).
    /// Default `1.0`; raising it shifts the blend toward travel time.
    pub eta_weight: f64,
    /// Constant added when the candidate stop lies opposite the
    /// car's committed travel direction.
    ///
    /// Default `0.0`; the Otis RSR lineage uses a large value so any
    /// right-direction candidate outranks any wrong-direction one.
    /// Ignored for cars in [`ElevatorPhase::Idle`] or stopped phases,
    /// since an idle car has no committed direction to be opposite to.
    pub wrong_direction_penalty: f64,
    /// Bonus subtracted when the candidate stop is already a car-call
    /// inside this car.
    ///
    /// Merges the new pickup with an existing dropoff instead of
    /// spawning an unrelated trip. Default `0.0`. Read from
    /// [`DispatchManifest::car_calls_for`](super::DispatchManifest::car_calls_for).
    pub coincident_car_call_bonus: f64,
    /// Coefficient on a smooth load-fraction penalty
    /// (`load_penalty_coeff · load_ratio`).
    ///
    /// Fires for partially loaded cars below the `bypass_load_*_pct`
    /// threshold enforced by [`pair_is_useful`];
    /// lets you prefer emptier cars for new pickups without an on/off cliff.
    /// Default `0.0`.
    pub load_penalty_coeff: f64,
    /// Multiplier applied to `wrong_direction_penalty` when the
    /// [`TrafficDetector`] classifies the current tick as
    /// [`TrafficMode::UpPeak`] or [`TrafficMode::DownPeak`].
    ///
    /// Default `1.0` (mode-agnostic — behaviour identical to pre-peak
    /// tuning). Raising it strengthens directional commitment during
    /// peaks where a car carrying a lobby-bound load shouldn't be
    /// pulled backwards to grab a new pickup. Off-peak periods keep
    /// the unscaled penalty, leaving inter-floor assignments free
    /// to reverse cheaply.
    ///
    /// Silently reduces to `1.0` when no `TrafficDetector` resource
    /// is installed — tests and custom sims that bypass the auto-install
    /// stay unaffected.
    pub peak_direction_multiplier: f64,
    /// Coefficient on the linear waiting-age fairness bonus. Each
    /// candidate stop's rank is reduced by `age_linear_weight · Σ
    /// wait_ticks` across waiting riders at the stop, so stops hosting
    /// older calls win ties without the quadratic blow-up a squared-
    /// wait term would have.
    ///
    /// Default `0.0` at [`new`](Self::new) / serde round-trip (keeps
    /// single-term mutant tests valid and snapshots backward-compatible);
    /// [`tuned`](Self::tuned) / [`default`](Self::default) ship `0.002`,
    /// lighter than ETD's 0.005 because RSR's wrong-direction penalty
    /// and `pair_is_useful` path guard already handle two starvation
    /// patterns that ETD needed the full-strength term for. See the
    /// inline comment at `tuned()` for the harness numbers behind the
    /// calibration.
    ///
    /// Mirrors the Lim 1983 / Barney–dos Santos 1985 CGC lineage
    /// already used in `EtdDispatch::age_linear_weight`; composes
    /// additively with the other penalty-stack terms.
    #[serde(default)]
    pub age_linear_weight: f64,
}

impl RsrDispatch {
    /// Create a new `RsrDispatch` with the baseline weights
    /// (`eta_weight = 1.0`, all penalties/bonuses disabled).
    ///
    /// This is the **additive-composition baseline** — every penalty
    /// and bonus is zero, so the rank reduces to
    /// [`NearestCarDispatch`](super::NearestCarDispatch) on distance
    /// alone. Useful for tests that want to measure a single term in
    /// isolation (`RsrDispatch::new().with_wrong_direction_penalty(…)`).
    ///
    /// For the opinionated "out-of-the-box RSR" configuration used by
    /// [`BuiltinStrategy::Rsr`](super::BuiltinStrategy::Rsr) and the
    /// playground, use [`RsrDispatch::default`] instead. `Default` ships
    /// with the full penalty stack turned on; `new()` is the empty
    /// canvas you build on top of.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            eta_weight: 1.0,
            wrong_direction_penalty: 0.0,
            coincident_car_call_bonus: 0.0,
            load_penalty_coeff: 0.0,
            peak_direction_multiplier: 1.0,
            age_linear_weight: 0.0,
        }
    }

    /// Return the opinionated tuned configuration — equivalent to
    /// [`Default::default`] but usable in `const` contexts.
    ///
    /// See [`RsrDispatch::default`] for the rationale behind each
    /// weight. The tuned stack ships with every penalty/bonus turned
    /// on so picking RSR out of the box is strictly richer than
    /// `NearestCar`, not identical to it.
    #[must_use]
    pub const fn tuned() -> Self {
        Self {
            eta_weight: 1.0,
            // Chosen ≈ one shaft-length travel time on a typical 20-stop
            // commercial bank (≈15s), so a backward pickup costs as much
            // as the trip to serve it. Large enough to dominate the ETA
            // term for a close-but-wrong-direction candidate; small
            // enough that off-peak inter-floor reversals still flip when
            // the demand strongly favours them.
            wrong_direction_penalty: 15.0,
            // Small merge bonus — prefer a car with a matching car-call
            // over spawning a new trip, but not so large it overrides a
            // much closer empty car.
            coincident_car_call_bonus: 5.0,
            // Light load-balancing — prefer empty cars for new work
            // when cars are otherwise tied.
            load_penalty_coeff: 3.0,
            // Strong directional commitment during up-peak / down-peak
            // (lobby-bound loads shouldn't reverse for new pickups).
            // Off-peak stays unscaled for cheap inter-floor reversals.
            peak_direction_multiplier: 2.0,
            // Linear waiting-age fairness. Tuned at 0.002 against the
            // `playground_audit` harness — lighter than ETD's 0.005
            // default because RSR's wrong-direction penalty and
            // `pair_is_useful` path guard already handle two of the
            // starvation patterns ETD needed the full-strength term
            // for. At 0.002 on the audit skyscraper (2 cars, heavy
            // peak), RSR beats SCAN across delivered / avg_wait /
            // max_wait; at 0.005 the same scenario improves further
            // but bleeds a few extra abandonments out of the
            // single-car office run as the age bonus pulls the lone
            // car off fresh demand faster than it can cycle. 0.002
            // is the tighter balance.
            age_linear_weight: 0.002,
        }
    }

    /// Set the wrong-direction penalty.
    ///
    /// # Panics
    /// Panics on non-finite or negative weights — a negative penalty
    /// would invert the direction ordering, silently preferring
    /// wrong-direction candidates.
    #[must_use]
    pub fn with_wrong_direction_penalty(mut self, weight: f64) -> Self {
        assert!(
            weight.is_finite() && weight >= 0.0,
            "wrong_direction_penalty must be finite and non-negative, got {weight}"
        );
        self.wrong_direction_penalty = weight;
        self
    }

    /// Set the coincident-car-call bonus.
    ///
    /// # Panics
    /// Panics on non-finite or negative weights — the bonus is
    /// subtracted, so a negative value would become a penalty.
    #[must_use]
    pub fn with_coincident_car_call_bonus(mut self, weight: f64) -> Self {
        assert!(
            weight.is_finite() && weight >= 0.0,
            "coincident_car_call_bonus must be finite and non-negative, got {weight}"
        );
        self.coincident_car_call_bonus = weight;
        self
    }

    /// Set the load-penalty coefficient.
    ///
    /// # Panics
    /// Panics on non-finite or negative weights.
    #[must_use]
    pub fn with_load_penalty_coeff(mut self, weight: f64) -> Self {
        assert!(
            weight.is_finite() && weight >= 0.0,
            "load_penalty_coeff must be finite and non-negative, got {weight}"
        );
        self.load_penalty_coeff = weight;
        self
    }

    /// Set the ETA weight.
    ///
    /// # Panics
    /// Panics on non-finite or negative weights. Zero is allowed and
    /// reduces the strategy to penalty/bonus tiebreaking alone.
    #[must_use]
    pub fn with_eta_weight(mut self, weight: f64) -> Self {
        assert!(
            weight.is_finite() && weight >= 0.0,
            "eta_weight must be finite and non-negative, got {weight}"
        );
        self.eta_weight = weight;
        self
    }

    /// Set the linear waiting-age fairness weight. Composes additively
    /// with the other penalty/bonus terms; `0.0` disables the term.
    ///
    /// # Panics
    /// Panics on non-finite or negative weights. A `NaN` weight would
    /// propagate through `mul_add` and silently collapse the stack;
    /// a negative weight would invert the fairness ordering.
    #[must_use]
    pub fn with_age_linear_weight(mut self, weight: f64) -> Self {
        assert!(
            weight.is_finite() && weight >= 0.0,
            "age_linear_weight must be finite and non-negative, got {weight}"
        );
        self.age_linear_weight = weight;
        self
    }

    /// Set the peak-direction multiplier.
    ///
    /// # Panics
    /// Panics on non-finite or sub-1.0 values. A multiplier below `1.0`
    /// would *weaken* the direction penalty during peaks (the opposite
    /// of the intent) — explicitly disallowed so a typo doesn't silently
    /// invert the tuning.
    #[must_use]
    pub fn with_peak_direction_multiplier(mut self, factor: f64) -> Self {
        assert!(
            factor.is_finite() && factor >= 1.0,
            "peak_direction_multiplier must be finite and ≥ 1.0, got {factor}"
        );
        self.peak_direction_multiplier = factor;
        self
    }
}

impl Default for RsrDispatch {
    /// The opinionated "pick RSR from the dropdown" configuration.
    ///
    /// Defaults to [`RsrDispatch::tuned`] — every penalty and bonus
    /// turned on with values calibrated to a 20-stop commercial bank.
    /// Before this default was tuned, `RsrDispatch::default()`
    /// reduced to the raw [`NearestCarDispatch`](super::NearestCarDispatch)
    /// baseline: picking "RSR" in the playground produced worse
    /// behaviour than picking "Nearest Car" (no direction discipline,
    /// no load balancing, no car-call merging). The tuned default
    /// fixes that without making any term mandatory — consumers
    /// wanting the zero baseline can still call
    /// [`RsrDispatch::new`].
    fn default() -> Self {
        Self::tuned()
    }
}

impl DispatchStrategy for RsrDispatch {
    fn rank(&mut self, ctx: &RankContext<'_>) -> Option<f64> {
        // `pair_is_useful(ctx, true)` enables the aboard-rider path
        // guard on top of the servability check. Without it, a loaded
        // RSR car gets pulled off the path to its aboard riders'
        // destinations by closer pickups — the same "never reaches
        // the passenger's desired stop" loop that NearestCar
        // specifically fixes. RSR's `wrong_direction_penalty` can
        // mitigate this when configured, but the guard is a
        // correctness floor independent of tuning.
        if !pair_is_useful(ctx, true) {
            return None;
        }
        let car = ctx.world.elevator(ctx.car)?;

        // ETA — travel time to the candidate stop.
        let distance = (ctx.car_position - ctx.stop_position).abs();
        let max_speed = car.max_speed.value();
        if max_speed <= 0.0 {
            return None;
        }
        let travel_time = distance / max_speed;
        let mut cost = self.eta_weight * travel_time;

        // Wrong-direction penalty. Only applies when the car has a
        // committed direction (not Idle / Stopped) — an idle car can
        // accept any candidate without "reversing" anything.
        if self.wrong_direction_penalty > 0.0
            && let Some(target) = car.phase.moving_target()
            && let Some(target_pos) = ctx.world.stop_position(target)
        {
            let car_going_up = target_pos > ctx.car_position;
            let car_going_down = target_pos < ctx.car_position;
            let cand_above = ctx.stop_position > ctx.car_position;
            let cand_below = ctx.stop_position < ctx.car_position;
            if (car_going_up && cand_below) || (car_going_down && cand_above) {
                // During up-peak/down-peak the directional invariant
                // is load-bearing (a committed car shouldn't reverse
                // to grab a new pickup), so scale the penalty up.
                // Off-peak, the base value still rules — inter-floor
                // traffic wants cheap reversals.
                let scaled = self.wrong_direction_penalty
                    * peak_scaling(ctx, self.peak_direction_multiplier);
                cost += scaled;
            }
        }

        // Coincident-car-call bonus — the candidate stop is already a
        // committed dropoff for this car.
        if self.coincident_car_call_bonus > 0.0
            && ctx
                .manifest
                .car_calls_for(ctx.car)
                .iter()
                .any(|c: &CarCall| c.floor == ctx.stop)
        {
            cost -= self.coincident_car_call_bonus;
        }

        // Smooth load-fraction penalty. `pair_is_useful` has already
        // filtered over-capacity and bypass-threshold cases; this term
        // shapes preference among the survivors so emptier cars win
        // pickups when all else is equal. Idle cars contribute zero.
        if self.load_penalty_coeff > 0.0 && car.phase() != ElevatorPhase::Idle {
            let capacity = car.weight_capacity().value();
            if capacity > 0.0 {
                let load_ratio = (car.current_load().value() / capacity).clamp(0.0, 1.0);
                cost += self.load_penalty_coeff * load_ratio;
            }
        }

        // Linear waiting-age fairness bonus. Pulls stale calls forward
        // without the quadratic blow-up a squared-wait term would have;
        // prevents fresh lobby-side demand from indefinitely preempting
        // older upper-floor waiters. Mirrors ETD's `age_linear_weight`.
        if self.age_linear_weight > 0.0 {
            let wait_sum: f64 = ctx
                .manifest
                .waiting_riders_at(ctx.stop)
                .iter()
                .map(|r| r.wait_ticks as f64)
                .sum();
            cost = crate::fp::fma(self.age_linear_weight, -wait_sum, cost);
        }

        let cost = cost.max(0.0);
        if cost.is_finite() { Some(cost) } else { None }
    }

    fn builtin_id(&self) -> Option<super::BuiltinStrategy> {
        Some(super::BuiltinStrategy::Rsr)
    }

    fn snapshot_config(&self) -> Option<String> {
        ron::to_string(self).ok()
    }

    fn restore_config(&mut self, serialized: &str) -> Result<(), String> {
        let restored: Self = ron::from_str(serialized).map_err(|e| e.to_string())?;
        *self = restored;
        Ok(())
    }
}