use proptest::prelude::*;
use crate::components::{Accel, RiderPhase, RiderPhaseKind, Speed, Weight};
use crate::config::{
BuildingConfig, ElevatorConfig, PassengerSpawnConfig, SimConfig, SimulationParams,
};
use crate::dispatch::{
HallCallMode, destination::DestinationDispatch, etd::EtdDispatch, look::LookDispatch,
nearest_car::NearestCarDispatch, rsr::RsrDispatch, scan::ScanDispatch,
};
use crate::metrics::Metrics;
use crate::rider_index::RiderIndex;
use crate::sim::Simulation;
use crate::stop::{StopConfig, StopId};
#[derive(Debug, Clone, Copy)]
enum StrategyKind {
Scan,
Look,
NearestCar,
Etd,
Rsr,
Destination,
}
impl StrategyKind {
fn label(self) -> &'static str {
match self {
Self::Scan => "Scan",
Self::Look => "Look",
Self::NearestCar => "NearestCar",
Self::Etd => "Etd",
Self::Rsr => "Rsr",
Self::Destination => "Destination",
}
}
}
#[derive(Debug, Clone, Copy)]
struct RiderSpawn {
origin: u32,
destination: u32,
weight: f64,
}
#[derive(Debug, Clone)]
struct Workload {
stop_count: u32,
elevator_count: u32,
capacity: f64,
spawns: Vec<RiderSpawn>,
}
impl Workload {
fn to_config(&self) -> SimConfig {
let stops: Vec<StopConfig> = (0..self.stop_count)
.map(|i| StopConfig {
id: StopId(i),
name: format!("Floor {i}"),
position: f64::from(i) * 4.0,
})
.collect();
let elevators: Vec<ElevatorConfig> = (0..self.elevator_count)
.map(|i| ElevatorConfig {
id: i,
name: format!("Car {i}"),
max_speed: Speed::from(3.0),
acceleration: Accel::from(1.5),
deceleration: Accel::from(2.0),
weight_capacity: Weight::from(self.capacity),
starting_stop: StopId(0),
door_open_ticks: 8,
door_transition_ticks: 4,
restricted_stops: Vec::new(),
#[cfg(feature = "energy")]
energy_profile: None,
service_mode: None,
inspection_speed_factor: 0.25,
bypass_load_up_pct: None,
bypass_load_down_pct: None,
})
.collect();
SimConfig {
schema_version: crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
building: BuildingConfig {
name: "Invariant Building".into(),
stops,
lines: None,
groups: None,
},
elevators,
simulation: SimulationParams {
ticks_per_second: 60.0,
},
passenger_spawning: PassengerSpawnConfig {
mean_interval_ticks: 120,
weight_range: (50.0, 100.0),
},
}
}
}
fn any_strategy() -> impl Strategy<Value = StrategyKind> {
prop_oneof![
Just(StrategyKind::Scan),
Just(StrategyKind::Look),
Just(StrategyKind::NearestCar),
Just(StrategyKind::Etd),
Just(StrategyKind::Rsr),
Just(StrategyKind::Destination),
]
}
fn any_workload() -> impl Strategy<Value = Workload> {
(3u32..=6, 1u32..=3, 600.0..=1200.0_f64, 5usize..=30).prop_flat_map(
|(stop_count, elevator_count, capacity, spawn_count)| {
let spawns = prop::collection::vec(
(0..stop_count, 1..stop_count, 60.0..=100.0_f64).prop_map(
move |(origin, delta, weight)| RiderSpawn {
origin,
destination: (origin + delta) % stop_count,
weight,
},
),
spawn_count,
);
spawns.prop_map(move |spawns| Workload {
stop_count,
elevator_count,
capacity,
spawns,
})
},
)
}
fn build_sim(
kind: StrategyKind,
workload: &Workload,
) -> (Simulation, Vec<crate::entity::EntityId>) {
let config = workload.to_config();
let mut sim = match kind {
StrategyKind::Scan => Simulation::new(&config, ScanDispatch::new()),
StrategyKind::Look => Simulation::new(&config, LookDispatch::new()),
StrategyKind::NearestCar => Simulation::new(&config, NearestCarDispatch::new()),
StrategyKind::Etd => Simulation::new(&config, EtdDispatch::new()),
StrategyKind::Rsr => Simulation::new(&config, RsrDispatch::new()),
StrategyKind::Destination => Simulation::new(&config, DestinationDispatch::new()),
}
.expect("build sim");
if matches!(kind, StrategyKind::Destination) {
for group in sim.groups_mut() {
group.set_hall_call_mode(HallCallMode::Destination);
}
}
for spawn in &workload.spawns {
sim.spawn_rider(
StopId(spawn.origin),
StopId(spawn.destination),
spawn.weight,
)
.expect("spawn rider");
}
let stops: Vec<_> = sim.world().iter_stops().map(|(eid, _)| eid).collect();
(sim, stops)
}
#[derive(Debug, Clone, Copy)]
struct MonotoneSnapshot {
total_delivered: u64,
total_abandoned: u64,
total_settled: u64,
total_rerouted: u64,
max_wait_time: u64,
total_distance: f64,
reposition_distance: f64,
total_moves: u64,
}
impl MonotoneSnapshot {
fn capture(m: &Metrics) -> Self {
Self {
total_delivered: m.total_delivered(),
total_abandoned: m.total_abandoned(),
total_settled: m.total_settled(),
total_rerouted: m.total_rerouted(),
max_wait_time: m.max_wait_time(),
total_distance: m.total_distance(),
reposition_distance: m.reposition_distance(),
total_moves: m.total_moves(),
}
}
}
const TICK_BUDGET: u64 = 8_000;
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
#[test]
fn conservation_holds_across_strategies(
kind in any_strategy(),
workload in any_workload(),
) {
let (mut sim, _stops) = build_sim(kind, &workload);
let expected_spawned = workload.spawns.len() as u64;
let label = kind.label();
for tick in 0..TICK_BUDGET {
sim.step();
let _ = sim.drain_events();
let actual = sim.world().iter_riders().count() as u64;
prop_assert_eq!(
actual, expected_spawned,
"[{}] tick {}: rider count {} != spawned {}",
label, tick, actual, expected_spawned,
);
let total_spawned = sim.metrics().total_spawned();
prop_assert_eq!(
total_spawned, expected_spawned,
"[{}] tick {}: metrics.total_spawned() = {} expected {}",
label, tick, total_spawned, expected_spawned,
);
let abandoned_in_phase = sim
.world()
.iter_riders()
.filter(|(_, r)| r.phase == RiderPhase::Abandoned)
.count() as u64;
let total_abandoned = sim.metrics().total_abandoned();
prop_assert_eq!(
abandoned_in_phase, total_abandoned,
"[{}] tick {}: abandoned phase {} != total_abandoned {}",
label, tick, abandoned_in_phase, total_abandoned,
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
#[test]
fn capacity_never_exceeded_across_strategies(
kind in any_strategy(),
workload in any_workload(),
) {
let (mut sim, _stops) = build_sim(kind, &workload);
let label = kind.label();
for tick in 0..TICK_BUDGET {
sim.step();
let _ = sim.drain_events();
for (_, _, elev) in sim.world().iter_elevators() {
let load = elev.current_load().value();
let cap = elev.weight_capacity().value();
prop_assert!(
load <= cap + 1e-9,
"[{}] tick {}: load {} > capacity {}",
label, tick, load, cap,
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
#[test]
fn rider_index_consistent_across_strategies(
kind in any_strategy(),
workload in any_workload(),
) {
let (mut sim, stops) = build_sim(kind, &workload);
let label = kind.label();
for tick in 0..TICK_BUDGET {
sim.step();
let _ = sim.drain_events();
let mut fresh = RiderIndex::default();
fresh.rebuild(sim.world());
for &stop in &stops {
let live = sim.waiting_count_at(stop);
let rebuilt = fresh.waiting_count_at(stop);
prop_assert_eq!(
live, rebuilt,
"[{}] tick {}: waiting mismatch at {:?}: live={}, rebuilt={}",
label, tick, stop, live, rebuilt,
);
let live = sim.resident_count_at(stop);
let rebuilt = fresh.resident_count_at(stop);
prop_assert_eq!(
live, rebuilt,
"[{}] tick {}: resident mismatch at {:?}: live={}, rebuilt={}",
label, tick, stop, live, rebuilt,
);
let live = sim.abandoned_count_at(stop);
let rebuilt = fresh.abandoned_count_at(stop);
prop_assert_eq!(
live, rebuilt,
"[{}] tick {}: abandoned mismatch at {:?}: live={}, rebuilt={}",
label, tick, stop, live, rebuilt,
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
#[test]
fn metrics_monotone_across_strategies(
kind in any_strategy(),
workload in any_workload(),
) {
let (mut sim, _stops) = build_sim(kind, &workload);
let mut prev = MonotoneSnapshot::capture(sim.metrics());
let label = kind.label();
for tick in 0..TICK_BUDGET {
sim.step();
let _ = sim.drain_events();
let cur = MonotoneSnapshot::capture(sim.metrics());
prop_assert!(
cur.total_delivered >= prev.total_delivered,
"[{}] tick {}: total_delivered decreased {} -> {}",
label, tick, prev.total_delivered, cur.total_delivered,
);
prop_assert!(
cur.total_abandoned >= prev.total_abandoned,
"[{}] tick {}: total_abandoned decreased {} -> {}",
label, tick, prev.total_abandoned, cur.total_abandoned,
);
prop_assert!(
cur.total_settled >= prev.total_settled,
"[{}] tick {}: total_settled decreased {} -> {}",
label, tick, prev.total_settled, cur.total_settled,
);
prop_assert!(
cur.total_rerouted >= prev.total_rerouted,
"[{}] tick {}: total_rerouted decreased {} -> {}",
label, tick, prev.total_rerouted, cur.total_rerouted,
);
prop_assert!(
cur.max_wait_time >= prev.max_wait_time,
"[{}] tick {}: max_wait_time decreased {} -> {}",
label, tick, prev.max_wait_time, cur.max_wait_time,
);
prop_assert!(
cur.total_moves >= prev.total_moves,
"[{}] tick {}: total_moves decreased {} -> {}",
label, tick, prev.total_moves, cur.total_moves,
);
prop_assert!(
cur.total_distance + 1e-9 >= prev.total_distance,
"[{}] tick {}: total_distance decreased {} -> {}",
label, tick, prev.total_distance, cur.total_distance,
);
prop_assert!(
cur.reposition_distance + 1e-9 >= prev.reposition_distance,
"[{}] tick {}: reposition_distance decreased {} -> {}",
label, tick, prev.reposition_distance, cur.reposition_distance,
);
prev = cur;
}
}
}
fn any_live_strategy() -> impl Strategy<Value = StrategyKind> {
any_strategy()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12))]
#[test]
fn all_riders_reach_terminal_phase_across_strategies(
kind in any_live_strategy(),
workload in any_workload(),
) {
let (mut sim, _stops) = build_sim(kind, &workload);
let label = kind.label();
for _ in 0..TICK_BUDGET {
sim.step();
let _ = sim.drain_events();
}
for (id, rider) in sim.world().iter_riders() {
let terminal = matches!(
rider.phase,
RiderPhase::Arrived | RiderPhase::Abandoned | RiderPhase::Resident,
);
prop_assert!(
terminal,
"[{}] rider {:?} stuck in non-terminal phase {:?} after {} ticks",
label, id, rider.phase, TICK_BUDGET,
);
}
}
}
const WARMUP_TICKS: u64 = 400;
const COMPARE_TICKS: u64 = 1_200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct PhaseHistogram {
waiting: usize,
boarding: usize,
riding: usize,
exiting: usize,
walking: usize,
arrived: usize,
abandoned: usize,
resident: usize,
}
fn phase_histogram(sim: &Simulation) -> PhaseHistogram {
let mut h = PhaseHistogram::default();
for (_, rider) in sim.world().iter_riders() {
match rider.phase.kind() {
RiderPhaseKind::Waiting => h.waiting += 1,
RiderPhaseKind::Boarding => h.boarding += 1,
RiderPhaseKind::Riding => h.riding += 1,
RiderPhaseKind::Exiting => h.exiting += 1,
RiderPhaseKind::Walking => h.walking += 1,
RiderPhaseKind::Arrived => h.arrived += 1,
RiderPhaseKind::Abandoned => h.abandoned += 1,
RiderPhaseKind::Resident => h.resident += 1,
}
}
h
}
fn any_snapshottable_strategy() -> impl Strategy<Value = StrategyKind> {
any_strategy()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(8))]
#[test]
fn snapshot_restore_preserves_trajectory_across_strategies(
kind in any_snapshottable_strategy(),
workload in any_workload(),
) {
let (mut sim_a, _stops) = build_sim(kind, &workload);
let label = kind.label();
for _ in 0..WARMUP_TICKS {
sim_a.step();
let _ = sim_a.drain_events();
}
let snap = sim_a.snapshot();
let mut sim_b = snap.restore(None).expect("restore");
for tick in 0..COMPARE_TICKS {
sim_a.step();
let _ = sim_a.drain_events();
sim_b.step();
let _ = sim_b.drain_events();
let ma = sim_a.metrics();
let mb = sim_b.metrics();
prop_assert_eq!(
ma.total_delivered(), mb.total_delivered(),
"[{}] compare tick {}: total_delivered drift", label, tick,
);
prop_assert_eq!(
ma.total_abandoned(), mb.total_abandoned(),
"[{}] compare tick {}: total_abandoned drift", label, tick,
);
prop_assert_eq!(
ma.total_spawned(), mb.total_spawned(),
"[{}] compare tick {}: total_spawned drift", label, tick,
);
prop_assert_eq!(
ma.max_wait_time(), mb.max_wait_time(),
"[{}] compare tick {}: max_wait_time drift", label, tick,
);
prop_assert_eq!(
ma.total_moves(), mb.total_moves(),
"[{}] compare tick {}: total_moves drift", label, tick,
);
let ha = phase_histogram(&sim_a);
let hb = phase_histogram(&sim_b);
prop_assert_eq!(
&ha, &hb,
"[{}] compare tick {}: phase histogram drift\nlive: {:?}\nrestored: {:?}",
label, tick, ha, hb,
);
}
}
}