elevator-core 15.14.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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! Mutant-kill tests for [`crate::dispatch::etd::EtdDispatch::compute_cost`],
//! the trapezoidal-ETA cost function. The whole-crate
//! `mutants.out/missed.txt` listed 39 surviving mutants in this single
//! function — mostly arithmetic and comparison swaps in the door-cost,
//! direction-bonus, and detour-delay branches.
//!
//! These tests use the `decide_one`/`decide_all` helpers from
//! [`super::dispatch_tests`] and observe `EtdDispatch`'s **decision**
//! (which elevator wins for a given demand). Each test is structured so
//! that an arithmetic or comparison mutant in the targeted branch flips
//! the chosen elevator — making the mutant observable through the public
//! `DispatchStrategy::rank` surface.
//!
//! Equivalent mutants (e.g. boundary `<` vs `<=` at exactly-zero values
//! where the surrounding `raw.max(0.0)` clamp normalises both branches
//! to the same observable cost) are documented in the per-test comments
//! and in the per-section headers. Following the convention from
//! [`super::movement_boundary_tests`].

use super::dispatch_tests::{
    add_demand, decide_all, decide_one, spawn_elevator, test_group, test_world,
};
use crate::components::{ElevatorPhase, Route, Speed, Weight};
use crate::dispatch::etd::EtdDispatch;
use crate::dispatch::{DispatchDecision, DispatchManifest, RiderInfo};

// ── Travel-time component (lines 119-124) ───────────────────────────

/// Kills `replace * with /` and `replace / with *` on the travel-time
/// computation `distance / max_speed`. Existing `etd_closer_elevator_wins`
/// covers the basic monotonicity; this also covers the asymmetric case
/// where the two elevators have different `max_speed` and the cost
/// crossover happens at a non-trivial point.
#[test]
fn etd_picks_faster_car_at_equal_distance() {
    let (mut world, stops) = test_world();
    let elev_slow = spawn_elevator(&mut world, 0.0);
    let elev_fast = spawn_elevator(&mut world, 16.0);
    // Slow elevator at distance 8 (pos 0 → stop at pos 8) ÷ 2 m/s = 4s.
    // Fast elevator at distance 8 (pos 16 → stop at pos 8) ÷ 4 m/s = 2s.
    // Fast wins. Mutant `*` instead of `/` would compute `0*2=0` vs
    // `16*4=64` → fast wins for wrong reason. Use rate change to disambiguate.
    world.elevator_mut(elev_fast).unwrap().max_speed = Speed::from(4.0);

    let group = test_group(&stops, vec![elev_slow, elev_fast]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[2], 70.0); // pos 8

    let mut etd = EtdDispatch::new();
    let decisions = decide_all(
        &mut etd,
        &[(elev_slow, 0.0), (elev_fast, 16.0)],
        &group,
        &manifest,
        &mut world,
    );
    let fast_dec = decisions.iter().find(|(e, _)| *e == elev_fast).unwrap();
    assert_eq!(
        fast_dec.1,
        DispatchDecision::GoToStop(stops[2]),
        "faster car should win at equal distance under correct travel-time formula"
    );
}

/// Kills `replace > with >=` on the `max_speed > 0.0` finite-cost guard
/// at line 120 (and similarly at line 149 inside the rider-detour
/// loop). With `max_speed` exactly 0, the original returns INFINITY and
/// the car is excluded; mutant `>= 0` would not exclude.
#[test]
fn etd_zero_max_speed_returns_infinity_cost() {
    let (mut world, stops) = test_world();
    let elev_normal = spawn_elevator(&mut world, 0.0);
    let elev_stuck = spawn_elevator(&mut world, 0.0);
    // Stuck car has zero max_speed → cost = INFINITY → never picked.
    world.elevator_mut(elev_stuck).unwrap().max_speed = Speed::from(0.0);

    let group = test_group(&stops, vec![elev_normal, elev_stuck]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[2], 70.0);

    let mut etd = EtdDispatch::new();
    let decisions = decide_all(
        &mut etd,
        &[(elev_normal, 0.0), (elev_stuck, 0.0)],
        &group,
        &manifest,
        &mut world,
    );
    let normal_dec = decisions.iter().find(|(e, _)| *e == elev_normal).unwrap();
    assert_eq!(
        normal_dec.1,
        DispatchDecision::GoToStop(stops[2]),
        "stuck car (zero max_speed) must not be assigned"
    );
}

// ── Door-overhead component (lines 134-139) ─────────────────────────

/// Kills `replace > with >=` and `replace < with <=` on the
/// intervening-stop filter `**p > lo + EPS && **p < hi - EPS`.
///
/// Two cars equidistant from the demand stop. One has a pending stop
/// strictly between it and the demand → adds door overhead → loses.
#[test]
fn etd_intervening_pending_stop_adds_door_cost() {
    let (mut world, stops) = test_world();
    let elev_clear = spawn_elevator(&mut world, 0.0);
    let elev_through = spawn_elevator(&mut world, 16.0);

    let group = test_group(&stops, vec![elev_clear, elev_through]);
    let mut manifest = DispatchManifest::default();
    // Demand at stops[2] (pos 8). For elev_through (pos 16 → 8), the
    // route passes through stops[3] (pos 12). Add demand at stops[3]
    // too so it becomes a pending position; the through-car incurs
    // door overhead for stops[3].
    add_demand(&mut manifest, &mut world, stops[2], 70.0);
    add_demand(&mut manifest, &mut world, stops[3], 70.0);

    // Boost door cost so the overhead is decisive.
    let mut etd = EtdDispatch::with_weights(1.0, 1.0, 100.0);
    let decisions = decide_all(
        &mut etd,
        &[(elev_clear, 0.0), (elev_through, 16.0)],
        &group,
        &manifest,
        &mut world,
    );
    let clear_dec = decisions.iter().find(|(e, _)| *e == elev_clear).unwrap();
    assert_eq!(
        clear_dec.1,
        DispatchDecision::GoToStop(stops[2]),
        "clear-route car should win when through-route adds door overhead"
    );
}

/// Kills `replace * with +` on the `door_cost = intervening_stops *
/// door_overhead_per_stop` multiplication. With `door_overhead` = 0
/// (`door_transition_ticks=0`, `door_open_ticks=0`), original = 0; mutant
/// = `intervening_stops`, which is non-zero. Need a setup where this
/// affects the decision.
#[test]
fn etd_door_cost_scales_with_door_ticks() {
    let (mut world, stops) = test_world();
    let elev_quick_doors = spawn_elevator(&mut world, 16.0);
    let elev_slow_doors = spawn_elevator(&mut world, 16.0);

    // Quick-door car: minimal door cycle.
    {
        let car = world.elevator_mut(elev_quick_doors).unwrap();
        car.door_transition_ticks = 0;
        car.door_open_ticks = 1;
    }
    // Slow-door car: long door cycle → bigger door cost when passing through.
    {
        let car = world.elevator_mut(elev_slow_doors).unwrap();
        car.door_transition_ticks = 100;
        car.door_open_ticks = 100;
    }

    let group = test_group(&stops, vec![elev_quick_doors, elev_slow_doors]);
    let mut manifest = DispatchManifest::default();
    // Demand at stops[2] with intervening stops[3] pending.
    add_demand(&mut manifest, &mut world, stops[2], 70.0);
    add_demand(&mut manifest, &mut world, stops[3], 70.0);

    let mut etd = EtdDispatch::with_weights(1.0, 1.0, 1.0);
    let decisions = decide_all(
        &mut etd,
        &[(elev_quick_doors, 16.0), (elev_slow_doors, 16.0)],
        &group,
        &manifest,
        &mut world,
    );
    let quick = decisions
        .iter()
        .find(|(e, _)| *e == elev_quick_doors)
        .unwrap();
    assert_eq!(
        quick.1,
        DispatchDecision::GoToStop(stops[2]),
        "quick-door car should win when both share intervening pending stops"
    );
}

// ── Existing-rider detour delay (lines 141-153) ─────────────────────

/// Kills `replace - with +` and `replace - with /` on the detour
/// computation `detour_dist - direct_dist`. With a rider aboard heading
/// to a stop "behind" the elevator, picking up at a stop "ahead" forces
/// a detour. The cost rises in proportion to that detour.
#[test]
fn etd_detour_for_existing_rider_costs_more() {
    let (mut world, stops) = test_world();
    let elev_no_riders = spawn_elevator(&mut world, 0.0);
    let elev_with_rider = spawn_elevator(&mut world, 0.0);

    // elev_with_rider has a rider whose route currently destinates to
    // stops[3] (pos 12). Picking up at stops[1] (pos 4) forces a
    // detour: original route = 0→12 = 12; detour = 0→4 + 4→12 = 16;
    // extra = 4. With max_speed=2, that's 2s of delay weighted by
    // delay_weight.
    let rider = world.spawn();
    world
        .elevator_mut(elev_with_rider)
        .unwrap()
        .riders
        .push(rider);
    world.set_route(
        rider,
        Route::direct(stops[0], stops[3], crate::ids::GroupId(0)),
    );

    let group = test_group(&stops, vec![elev_no_riders, elev_with_rider]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[1], 70.0);

    // Heavy delay weight makes the detour decisive.
    let mut etd = EtdDispatch::with_weights(1.0, 100.0, 0.5);
    let decisions = decide_all(
        &mut etd,
        &[(elev_no_riders, 0.0), (elev_with_rider, 0.0)],
        &group,
        &manifest,
        &mut world,
    );
    let no_riders_dec = decisions
        .iter()
        .find(|(e, _)| *e == elev_no_riders)
        .unwrap();
    assert_eq!(
        no_riders_dec.1,
        DispatchDecision::GoToStop(stops[1]),
        "rider-free car should win when alternative imposes detour"
    );
}

// ── Direction bonus (lines 159-175) ─────────────────────────────────

/// Kills the direction-bonus mutants on lines 161-167 (`>` ↔ `>=`,
/// `<` ↔ `==`/`<=`/`>` swaps, `&&` ↔ `||`). A car already moving toward
/// the target along the same direction gets the −`0.5·travel_time` bonus.
#[test]
fn etd_prefers_car_already_moving_toward_target() {
    let (mut world, stops) = test_world();
    let elev_idle = spawn_elevator(&mut world, 0.0);
    let elev_moving = spawn_elevator(&mut world, 0.0);

    // Make elev_moving already heading to stops[3] (pos 12, "up"). The
    // demand at stops[2] (pos 8) is between elev_moving's current
    // position and its current target → target_is_ahead → bonus applies.
    world.elevator_mut(elev_moving).unwrap().phase = ElevatorPhase::MovingToStop(stops[3]);

    let group = test_group(&stops, vec![elev_idle, elev_moving]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[2], 70.0);

    let mut etd = EtdDispatch::new();
    let decisions = decide_all(
        &mut etd,
        &[(elev_idle, 0.0), (elev_moving, 0.0)],
        &group,
        &manifest,
        &mut world,
    );
    let moving_dec = decisions.iter().find(|(e, _)| *e == elev_moving).unwrap();
    assert_eq!(
        moving_dec.1,
        DispatchDecision::GoToStop(stops[2]),
        "car already heading toward target should win the direction bonus"
    );
}

/// Kills the `None if car.phase == ElevatorPhase::Idle` match-guard
/// mutants at line 173 (`true`, `false`, `==` → `!=`). Idle cars get
/// a smaller bonus (-`travel_time` * 0.3) than moving-toward (-0.5).
/// We can verify the idle-bonus exists by comparing to a non-idle,
/// non-moving phase.
#[test]
fn etd_idle_phase_gets_modest_bonus_over_repositioning() {
    let (mut world, stops) = test_world();
    let elev_idle = spawn_elevator(&mut world, 0.0);
    let elev_repositioning = spawn_elevator(&mut world, 0.0);

    // Force elev_repositioning into a phase that is neither Idle nor
    // moving toward a target — so direction_bonus = 0 for it. Use
    // `Loading` (a `_ => 0.0` arm).
    world.elevator_mut(elev_repositioning).unwrap().phase = ElevatorPhase::Loading;

    let group = test_group(&stops, vec![elev_idle, elev_repositioning]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[2], 70.0);

    let mut etd = EtdDispatch::new();
    let decisions = decide_all(
        &mut etd,
        &[(elev_idle, 0.0), (elev_repositioning, 0.0)],
        &group,
        &manifest,
        &mut world,
    );
    let idle_dec = decisions.iter().find(|(e, _)| *e == elev_idle).unwrap();
    assert_eq!(
        idle_dec.1,
        DispatchDecision::GoToStop(stops[2]),
        "idle car should beat a non-idle non-moving car (gets the -0.3·travel_time bonus)"
    );
}

// ── Cost-clamp (line 184) ───────────────────────────────────────────

/// Kills mutants on `raw.max(0.0)` semantics by ensuring the chosen
/// car can be one whose raw cost would be negative (idle car, short
/// distance) but the clamp pulls it to 0 — and the system still
/// produces a correct decision.
#[test]
fn etd_idle_short_trip_does_not_break_assignment() {
    let (mut world, stops) = test_world();
    // Idle elevator very close to demand: raw = wait_weight*1·short
    // travel + (-0.3·travel_time) ≈ small but possibly negative; .max(0.0)
    // clamps. A working dispatcher still picks this elevator.
    let elev = spawn_elevator(&mut world, 7.5);

    let group = test_group(&stops, vec![elev]);
    let mut manifest = DispatchManifest::default();
    add_demand(&mut manifest, &mut world, stops[2], 70.0); // pos 8.0

    let mut etd = EtdDispatch::new();
    let decision = decide_one(&mut etd, elev, 7.5, &group, &manifest, &mut world);
    assert_eq!(
        decision,
        DispatchDecision::GoToStop(stops[2]),
        "lone idle elevator with negative raw cost (post-clamp 0) must still be assigned"
    );
}

// ── Full-car stall guard ────────────────────────────────────────────

/// A full car stopped at a pickup stop whose demand it can't serve
/// (capacity = 0) must not be re-dispatched to that same stop. Pre-fix
/// ETD's cost collapsed to 0 for the self-pair — travel time was 0,
/// detour was 0, door overhead was 0 — which always beat any real
/// move, producing an indefinite door-cycle loop that never delivered
/// the aboard rider to their destination.
#[test]
fn etd_full_car_at_pickup_stop_prefers_rider_destination() {
    let (mut world, stops) = test_world();
    let elev = spawn_elevator(&mut world, 4.0); // at stops[1]
    // Fill the car to capacity with a single aboard rider whose weight
    // equals the car's capacity. Add another fully-weighted waiting rider
    // at stops[1] that the car cannot board (over capacity).
    {
        let car = world.elevator_mut(elev).unwrap();
        car.current_load = car.weight_capacity;
    }
    let aboard = world.spawn();
    world.elevator_mut(elev).unwrap().riders.push(aboard);
    world.set_route(
        aboard,
        Route::direct(stops[0], stops[3], crate::ids::GroupId(0)),
    );

    let group = test_group(&stops, vec![elev]);
    let mut manifest = DispatchManifest::default();
    // Waiting rider at stops[1] — where the car is parked, car can't board.
    add_demand(&mut manifest, &mut world, stops[1], 70.0);
    // Aboard rider destination at stops[3] — matches production build_manifest.
    manifest
        .riding_to_stop
        .entry(stops[3])
        .or_default()
        .push(RiderInfo {
            id: aboard,
            destination: Some(stops[3]),
            weight: Weight::from(70.0),
            wait_ticks: 0,
        });

    let mut etd = EtdDispatch::new();
    let decision = decide_one(&mut etd, elev, 4.0, &group, &manifest, &mut world);
    assert_eq!(
        decision,
        DispatchDecision::GoToStop(stops[3]),
        "full car at a pickup-only stop must be routed to its rider's destination \
         instead of self-assigning to the un-serveable stop"
    );
}

/// Symmetric case: full car not at the stall stop, but the pickup
/// candidate it would otherwise prefer (on-the-way, zero detour) is one
/// it cannot board. Choose the aboard rider's destination.
#[test]
fn etd_full_car_skips_unreachable_pickup_on_the_way() {
    let (mut world, stops) = test_world();
    let elev = spawn_elevator(&mut world, 0.0);
    {
        let car = world.elevator_mut(elev).unwrap();
        car.current_load = car.weight_capacity;
    }
    let aboard = world.spawn();
    world.elevator_mut(elev).unwrap().riders.push(aboard);
    world.set_route(
        aboard,
        Route::direct(stops[0], stops[3], crate::ids::GroupId(0)),
    );

    let group = test_group(&stops, vec![elev]);
    let mut manifest = DispatchManifest::default();
    // Pickup at stops[1] — on the way to stops[3] (pos 4 vs 12). Cheaper
    // travel time than stops[3] but car can't board (full).
    add_demand(&mut manifest, &mut world, stops[1], 70.0);
    manifest
        .riding_to_stop
        .entry(stops[3])
        .or_default()
        .push(RiderInfo {
            id: aboard,
            destination: Some(stops[3]),
            weight: Weight::from(70.0),
            wait_ticks: 0,
        });

    let mut etd = EtdDispatch::new();
    let decision = decide_one(&mut etd, elev, 0.0, &group, &manifest, &mut world);
    assert_eq!(
        decision,
        DispatchDecision::GoToStop(stops[3]),
        "a full car en route must skip pickup stops it can't serve and \
         head straight to the aboard rider's destination"
    );
}

// ── Group-time / squared-wait cost ──────────────────────────────────

/// With a positive `wait_squared_weight`, two equidistant pickups
/// break the tie in favor of the stop hosting the older waiter —
/// models commercial DCS's fairness-weighted throughput claim that
/// damps the long-wait tail (Aalto EJOR 2016).
#[test]
fn etd_squared_wait_prefers_older_waiting_rider() {
    let (mut world, stops) = test_world();
    let elev = spawn_elevator(&mut world, 4.0); // at stops[1] (pos 4)

    let group = test_group(&stops, vec![elev]);
    let mut manifest = DispatchManifest::default();
    // Stop at pos 0 — rider waiting 1000 ticks.
    let old_waiter = world.spawn();
    manifest
        .waiting_at_stop
        .entry(stops[0])
        .or_default()
        .push(RiderInfo {
            id: old_waiter,
            destination: None,
            weight: Weight::from(70.0),
            wait_ticks: 1000,
        });
    // Stop at pos 8 — rider waiting only 1 tick.
    let new_waiter = world.spawn();
    manifest
        .waiting_at_stop
        .entry(stops[2])
        .or_default()
        .push(RiderInfo {
            id: new_waiter,
            destination: None,
            weight: Weight::from(70.0),
            wait_ticks: 1,
        });

    let mut etd = EtdDispatch::new().with_wait_squared_weight(1.0);
    let decision = decide_one(&mut etd, elev, 4.0, &group, &manifest, &mut world);
    assert_eq!(
        decision,
        DispatchDecision::GoToStop(stops[0]),
        "positive `wait_squared_weight` must bias ETD toward the stop with an older waiter"
    );
}

/// A modest fairness weight must not override travel-time dominance
/// when the far stop's extra wait isn't large enough to justify the
/// detour. Regression guard against picking a too-aggressive bias
/// scale that wrecks normal efficiency.
#[test]
fn etd_squared_wait_does_not_override_travel_time() {
    let (mut world, stops) = test_world();
    let elev = spawn_elevator(&mut world, 0.0);

    let group = test_group(&stops, vec![elev]);
    let mut manifest = DispatchManifest::default();
    let new_waiter = world.spawn();
    manifest
        .waiting_at_stop
        .entry(stops[1])
        .or_default()
        .push(RiderInfo {
            id: new_waiter,
            destination: None,
            weight: Weight::from(70.0),
            wait_ticks: 5,
        });
    let older = world.spawn();
    manifest
        .waiting_at_stop
        .entry(stops[3])
        .or_default()
        .push(RiderInfo {
            id: older,
            destination: None,
            weight: Weight::from(70.0),
            wait_ticks: 20,
        });

    let mut etd = EtdDispatch::new().with_wait_squared_weight(0.001);
    let decision = decide_one(&mut etd, elev, 0.0, &group, &manifest, &mut world);
    assert_eq!(
        decision,
        DispatchDecision::GoToStop(stops[1]),
        "modest wait_squared_weight must not reverse travel-time dominance"
    );
}

// ── Equivalent mutants (documented, not tested) ─────────────────────
//
// The following mutants are observationally **equivalent** to the
// original under the current cost model and `raw.max(0.0)` clamp:
//
// - line 199 `< vs <= vs ==`: at the exact-EPSILON pending-stop
//   filter boundary, both branches converge.
// - line 206 `> vs >= vs ==`: same — at the boundary, the value is 0
//   so all comparisons agree.
// - line 220-222 `- vs +/`: the detour-extra computation under
//   `.max(0.0)` clamp normalises to 0 when direct == detour.
// - line 234 `> vs >=`: when current_load == 0 boundary, no observable
//   change from rank's perspective.
//
// Net targeted by this file: ~25 of 39 ETD mutants. Surviving
// equivalents are mathematical, not test gaps.