Skip to main content

elevator_core/sim/
construction.rs

1//! Simulation construction, validation, and topology assembly.
2//!
3//! Split out from `sim.rs` to keep each concern readable. Holds:
4//!
5//! - [`Simulation::new`] and [`Simulation::new_with_hooks`]
6//! - Config validation ([`Simulation::validate_config`] and helpers)
7//! - Legacy and explicit topology builders
8//! - [`Simulation::from_parts`] for snapshot restore
9//! - Dispatch, reposition, and hook registration helpers
10//!
11//! Since this is a child module of `crate::sim`, it can access `Simulation`'s
12//! private fields directly — no visibility relaxation required.
13
14use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Mutex;
16
17use crate::components::{Elevator, ElevatorPhase, Line, Orientation, Position, Stop, Velocity};
18use crate::config::SimConfig;
19use crate::dispatch::{
20    BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
21    RepositionStrategy,
22};
23use crate::door::DoorState;
24use crate::entity::EntityId;
25use crate::error::SimError;
26use crate::events::EventBus;
27use crate::hooks::{Phase, PhaseHooks};
28use crate::ids::GroupId;
29use crate::metrics::Metrics;
30use crate::rider_index::RiderIndex;
31use crate::stop::StopId;
32use crate::time::TimeAdapter;
33use crate::topology::TopologyGraph;
34use crate::world::World;
35
36use super::Simulation;
37
38/// Bundled topology result: groups, dispatchers, and strategy IDs.
39type TopologyResult = (
40    Vec<ElevatorGroup>,
41    BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
42    BTreeMap<GroupId, BuiltinStrategy>,
43);
44
45/// Ensure DCS groups have `HallCallMode::Destination` at construction
46/// time. Non-DCS groups are left at whatever the config specified —
47/// forcing Classic here would clobber explicit config overrides (e.g. a
48/// Scan group that the author deliberately set to Destination mode).
49///
50/// Runtime swaps via [`Simulation::set_dispatch`] do a full bidirectional
51/// sync because a strategy change is an explicit user action where
52/// resetting the mode is expected.
53fn sync_hall_call_modes(
54    groups: &mut [ElevatorGroup],
55    strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
56) {
57    for group in groups.iter_mut() {
58        if strategy_ids.get(&group.id()) == Some(&BuiltinStrategy::Destination) {
59            group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
60        }
61    }
62}
63
64/// Validate the physics fields shared by [`crate::config::ElevatorConfig`]
65/// and [`super::ElevatorParams`]. Both construction-time validation and
66/// the runtime `add_elevator` path call this so an invalid set of params
67/// can never reach the world (zeroes blow up movement; zero door ticks
68/// stall the door FSM).
69#[allow(clippy::too_many_arguments)]
70pub(super) fn validate_elevator_physics(
71    max_speed: f64,
72    acceleration: f64,
73    deceleration: f64,
74    weight_capacity: f64,
75    inspection_speed_factor: f64,
76    door_transition_ticks: u32,
77    door_open_ticks: u32,
78    bypass_load_up_pct: Option<f64>,
79    bypass_load_down_pct: Option<f64>,
80) -> Result<(), SimError> {
81    if !max_speed.is_finite() || max_speed <= 0.0 {
82        return Err(SimError::InvalidConfig {
83            field: "elevators.max_speed",
84            reason: format!("must be finite and positive, got {max_speed}"),
85        });
86    }
87    if !acceleration.is_finite() || acceleration <= 0.0 {
88        return Err(SimError::InvalidConfig {
89            field: "elevators.acceleration",
90            reason: format!("must be finite and positive, got {acceleration}"),
91        });
92    }
93    if !deceleration.is_finite() || deceleration <= 0.0 {
94        return Err(SimError::InvalidConfig {
95            field: "elevators.deceleration",
96            reason: format!("must be finite and positive, got {deceleration}"),
97        });
98    }
99    if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
100        return Err(SimError::InvalidConfig {
101            field: "elevators.weight_capacity",
102            reason: format!("must be finite and positive, got {weight_capacity}"),
103        });
104    }
105    if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
106        return Err(SimError::InvalidConfig {
107            field: "elevators.inspection_speed_factor",
108            reason: format!("must be finite and positive, got {inspection_speed_factor}"),
109        });
110    }
111    if door_transition_ticks == 0 {
112        return Err(SimError::InvalidConfig {
113            field: "elevators.door_transition_ticks",
114            reason: "must be > 0".into(),
115        });
116    }
117    if door_open_ticks == 0 {
118        return Err(SimError::InvalidConfig {
119            field: "elevators.door_open_ticks",
120            reason: "must be > 0".into(),
121        });
122    }
123    validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
124    validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
125    Ok(())
126}
127
128/// `bypass_load_{up,down}_pct` must be a finite fraction in `(0.0, 1.0]`
129/// when set. `pct = 0.0` would bypass at an empty car (nonsense); `NaN`
130/// and infinities silently disable the bypass under the dispatch guard,
131/// which is a silent foot-gun. Reject at config time instead.
132fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
133    let Some(pct) = pct else {
134        return Ok(());
135    };
136    if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
137        return Err(SimError::InvalidConfig {
138            field,
139            reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
140        });
141    }
142    Ok(())
143}
144
145impl Simulation {
146    /// Create a new simulation from config and a dispatch strategy.
147    ///
148    /// Returns `Err` if the config is invalid (zero stops, duplicate IDs,
149    /// negative speeds, etc.).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`SimError::InvalidConfig`] if the configuration has zero stops,
154    /// duplicate stop IDs, zero elevators, non-positive physics parameters,
155    /// invalid starting stops, or non-positive tick rate.
156    pub fn new(
157        config: &SimConfig,
158        dispatch: impl DispatchStrategy + 'static,
159    ) -> Result<Self, SimError> {
160        let mut dispatchers = BTreeMap::new();
161        dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
162        Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
163    }
164
165    /// Create a simulation with pre-configured lifecycle hooks.
166    ///
167    /// Used by [`SimulationBuilder`](crate::builder::SimulationBuilder).
168    #[allow(clippy::too_many_lines)]
169    pub(crate) fn new_with_hooks(
170        config: &SimConfig,
171        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
172        hooks: PhaseHooks,
173    ) -> Result<Self, SimError> {
174        Self::validate_config(config)?;
175
176        let mut world = World::new();
177
178        // Create stop entities.
179        let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
180        for sc in &config.building.stops {
181            let eid = world.spawn();
182            world.set_stop(
183                eid,
184                Stop {
185                    name: sc.name.clone(),
186                    position: sc.position,
187                },
188            );
189            world.set_position(eid, Position { value: sc.position });
190            stop_lookup.insert(sc.id, eid);
191        }
192
193        // Build sorted-stops index for O(log n) PassingFloor detection.
194        let mut sorted: Vec<(f64, EntityId)> = world
195            .iter_stops()
196            .map(|(eid, stop)| (stop.position, eid))
197            .collect();
198        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
199        world.insert_resource(crate::world::SortedStops(sorted));
200
201        // Per-stop arrival signal, appended on rider spawn and queried
202        // by dispatch/reposition strategies to drive traffic-mode
203        // switches and predictive parking. The destination mirror is
204        // what powers down-peak detection — without it the classifier
205        // sees `total_dest = 0` and silently never emits `DownPeak`.
206        // Both resources must exist before the first `RiderSpawned`
207        // event fires (i.e. before any user-driven `spawn_rider` call):
208        // `record_spawn` is fire-and-forget on missing resources, so a
209        // later insert wouldn't replay history.
210        world.insert_resource(crate::arrival_log::ArrivalLog::default());
211        world.insert_resource(crate::arrival_log::DestinationLog::default());
212        world.insert_resource(crate::arrival_log::CurrentTick::default());
213        world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
214        // Traffic-mode classifier. Auto-refreshed in the metrics phase
215        // from the same rolling window; strategies read the current
216        // mode via `World::resource::<TrafficDetector>()`.
217        world.insert_resource(crate::traffic_detector::TrafficDetector::default());
218        // Per-car reposition cooldown. Populated by the movement
219        // phase when a repositioning car arrives; consulted by the
220        // reposition phase to skip cars that just parked so the
221        // hot-stop ranking can't flip them around again the next
222        // tick.
223        world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
224        // Expose tick rate to strategies that need to unit-convert
225        // tick-denominated elevator fields (door cycle, ack latency)
226        // into the second-denominated terms of their cost functions.
227        // Without this, ETD's door-overhead term was summing ticks
228        // into a seconds expression and getting ~60× over-weighted.
229        world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
230
231        let (mut groups, dispatchers, strategy_ids) =
232            if let Some(line_configs) = &config.building.lines {
233                Self::build_explicit_topology(
234                    &mut world,
235                    config,
236                    line_configs,
237                    &stop_lookup,
238                    builder_dispatchers,
239                )
240            } else {
241                Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
242            };
243        sync_hall_call_modes(&mut groups, &strategy_ids);
244
245        let dt = 1.0 / config.simulation.ticks_per_second;
246
247        world.insert_resource(crate::tagged_metrics::MetricTags::default());
248
249        // Auto-register dispatch-internal extension types the sim itself
250        // owns. The same registration runs in `from_parts` (the
251        // snapshot-restore path); doing it here too means snapshot bytes
252        // taken from a fresh sim and from a restored sim agree on the
253        // extensions BTreeMap shape (#534's review surfaced this
254        // asymmetry: pre-fix, fresh sims had no `assigned_car` extension
255        // entry while restored sims did, breaking byte-equality of the
256        // snapshot bytes round-trip and the lockstep checksum).
257        world.register_ext::<crate::dispatch::destination::AssignedCar>(
258            crate::dispatch::destination::ASSIGNED_CAR_KEY,
259        );
260
261        // Collect line tag info (entity + name + elevator entities) before
262        // borrowing world mutably for MetricTags.
263        let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
264            .iter()
265            .flat_map(|group| {
266                group.lines().iter().filter_map(|li| {
267                    let line_comp = world.line(li.entity())?;
268                    Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
269                })
270            })
271            .collect();
272
273        // Tag line entities and their elevators with "line:{name}".
274        if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
275            for (line_eid, name, elevators) in &line_tag_info {
276                let tag = format!("line:{name}");
277                tags.tag(*line_eid, tag.clone());
278                for elev_eid in elevators {
279                    tags.tag(*elev_eid, tag.clone());
280                }
281            }
282        }
283
284        // Wire reposition strategies from group configs.
285        let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
286        let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
287        if let Some(group_configs) = &config.building.groups {
288            for gc in group_configs {
289                if let Some(ref repo_id) = gc.reposition
290                    && let Some(strategy) = repo_id.instantiate()
291                {
292                    let gid = GroupId(gc.id);
293                    repositioners.insert(gid, strategy);
294                    reposition_ids.insert(gid, repo_id.clone());
295                }
296            }
297        }
298
299        Ok(Self {
300            world,
301            events: EventBus::default(),
302            pending_output: Vec::new(),
303            tick: 0,
304            dt,
305            groups,
306            stop_lookup,
307            dispatchers,
308            strategy_ids,
309            repositioners,
310            reposition_ids,
311            metrics: Metrics::new(),
312            time: TimeAdapter::new(config.simulation.ticks_per_second),
313            hooks,
314            elevator_ids_buf: Vec::new(),
315            reposition_buf: Vec::new(),
316            dispatch_scratch: crate::dispatch::DispatchScratch::default(),
317            topo_graph: Mutex::new(TopologyGraph::new()),
318            rider_index: RiderIndex::default(),
319            tick_in_progress: false,
320        })
321    }
322
323    /// Spawn a single elevator entity from an `ElevatorConfig` onto `line`.
324    ///
325    /// Sets position, velocity, all `Elevator` fields, optional energy profile,
326    /// optional service mode, and an empty `DestinationQueue`.
327    /// Returns the new entity ID.
328    fn spawn_elevator_entity(
329        world: &mut World,
330        ec: &crate::config::ElevatorConfig,
331        line: EntityId,
332        stop_lookup: &HashMap<StopId, EntityId>,
333        start_pos_lookup: &[crate::stop::StopConfig],
334    ) -> EntityId {
335        let eid = world.spawn();
336        let start_pos = start_pos_lookup
337            .iter()
338            .find(|s| s.id == ec.starting_stop)
339            .map_or(0.0, |s| s.position);
340        world.set_position(eid, Position { value: start_pos });
341        world.set_velocity(eid, Velocity { value: 0.0 });
342        let restricted: HashSet<EntityId> = ec
343            .restricted_stops
344            .iter()
345            .filter_map(|sid| stop_lookup.get(sid).copied())
346            .collect();
347        world.set_elevator(
348            eid,
349            Elevator {
350                phase: ElevatorPhase::Idle,
351                door: DoorState::Closed,
352                max_speed: ec.max_speed,
353                acceleration: ec.acceleration,
354                deceleration: ec.deceleration,
355                weight_capacity: ec.weight_capacity,
356                current_load: crate::components::Weight::ZERO,
357                riders: Vec::new(),
358                target_stop: None,
359                door_transition_ticks: ec.door_transition_ticks,
360                door_open_ticks: ec.door_open_ticks,
361                line,
362                repositioning: false,
363                restricted_stops: restricted,
364                inspection_speed_factor: ec.inspection_speed_factor,
365                going_up: true,
366                going_down: true,
367                move_count: 0,
368                door_command_queue: Vec::new(),
369                manual_target_velocity: None,
370                bypass_load_up_pct: ec.bypass_load_up_pct,
371                bypass_load_down_pct: ec.bypass_load_down_pct,
372                home_stop: None,
373            },
374        );
375        #[cfg(feature = "energy")]
376        if let Some(ref profile) = ec.energy_profile {
377            world.set_energy_profile(eid, profile.clone());
378            world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
379        }
380        if let Some(mode) = ec.service_mode {
381            world.set_service_mode(eid, mode);
382        }
383        world.set_destination_queue(eid, crate::components::DestinationQueue::new());
384        eid
385    }
386
387    /// Build topology from the legacy flat elevator list (single default line + group).
388    fn build_legacy_topology(
389        world: &mut World,
390        config: &SimConfig,
391        stop_lookup: &HashMap<StopId, EntityId>,
392        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
393    ) -> TopologyResult {
394        // Iterate the config's stop list (deterministic Vec order) and
395        // resolve each through the lookup. Walking `stop_lookup.values()`
396        // would expose `HashMap` iteration order — which varies by
397        // per-process hash seed — into `LineInfo.serves` and from
398        // there into snapshot bytes.
399        let all_stop_entities: Vec<EntityId> = config
400            .building
401            .stops
402            .iter()
403            .filter_map(|s| stop_lookup.get(&s.id).copied())
404            .collect();
405        let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
406        let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
407        let max_pos = stop_positions
408            .iter()
409            .copied()
410            .fold(f64::NEG_INFINITY, f64::max);
411
412        let default_line_eid = world.spawn();
413        world.set_line(
414            default_line_eid,
415            Line {
416                name: "Default".into(),
417                group: GroupId(0),
418                orientation: Orientation::Vertical,
419                position: None,
420                min_position: min_pos,
421                max_position: max_pos,
422                max_cars: None,
423            },
424        );
425
426        let mut elevator_entities = Vec::new();
427        for ec in &config.elevators {
428            let eid = Self::spawn_elevator_entity(
429                world,
430                ec,
431                default_line_eid,
432                stop_lookup,
433                &config.building.stops,
434            );
435            elevator_entities.push(eid);
436        }
437
438        let default_line_info =
439            LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
440
441        let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
442
443        // Legacy topology has exactly one group: GroupId(0). Take a builder
444        // entry keyed on that group; ignore entries keyed on any other group
445        // (they would have nothing to attach to in the legacy schema).
446        let mut dispatchers = BTreeMap::new();
447        let mut strategy_ids = BTreeMap::new();
448        let user_dispatcher = builder_dispatchers
449            .into_iter()
450            .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
451        // Snapshot identity comes from the dispatcher's own `builtin_id`, not
452        // from a hard-coded variant — otherwise a snapshot round-trip would
453        // silently swap a custom strategy back to Scan. Strategies that
454        // return `None` fall back to Scan for snapshot fidelity.
455        let inferred_id = user_dispatcher
456            .as_ref()
457            .and_then(|d| d.builtin_id())
458            .unwrap_or(BuiltinStrategy::Scan);
459        if let Some(d) = user_dispatcher {
460            dispatchers.insert(GroupId(0), d);
461        } else {
462            dispatchers.insert(
463                GroupId(0),
464                Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
465            );
466        }
467        strategy_ids.insert(GroupId(0), inferred_id);
468
469        (vec![group], dispatchers, strategy_ids)
470    }
471
472    /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
473    #[allow(clippy::too_many_lines)]
474    fn build_explicit_topology(
475        world: &mut World,
476        config: &SimConfig,
477        line_configs: &[crate::config::LineConfig],
478        stop_lookup: &HashMap<StopId, EntityId>,
479        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
480    ) -> TopologyResult {
481        // Map line config id → (line EntityId, LineInfo). `BTreeMap`
482        // (not `HashMap`) so the auto-inferred-groups branch iterates
483        // `.values()` in deterministic key order — otherwise the
484        // resulting `LineInfo` sequence permutes across processes and
485        // leaks into snapshot bytes via `GroupSnapshot::lines`.
486        let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
487
488        for lc in line_configs {
489            // Resolve served stop entities.
490            let served_entities: Vec<EntityId> = lc
491                .serves
492                .iter()
493                .filter_map(|sid| stop_lookup.get(sid).copied())
494                .collect();
495
496            // Compute min/max from stops if not explicitly set.
497            let stop_positions: Vec<f64> = lc
498                .serves
499                .iter()
500                .filter_map(|sid| {
501                    config
502                        .building
503                        .stops
504                        .iter()
505                        .find(|s| s.id == *sid)
506                        .map(|s| s.position)
507                })
508                .collect();
509            let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
510            let auto_max = stop_positions
511                .iter()
512                .copied()
513                .fold(f64::NEG_INFINITY, f64::max);
514
515            let min_pos = lc.min_position.unwrap_or(auto_min);
516            let max_pos = lc.max_position.unwrap_or(auto_max);
517
518            let line_eid = world.spawn();
519            // The group assignment will be set when we process GroupConfigs.
520            // Default to GroupId(0) initially.
521            world.set_line(
522                line_eid,
523                Line {
524                    name: lc.name.clone(),
525                    group: GroupId(0),
526                    orientation: lc.orientation,
527                    position: lc.position,
528                    min_position: min_pos,
529                    max_position: max_pos,
530                    max_cars: lc.max_cars,
531                },
532            );
533
534            // Spawn elevators for this line.
535            let mut elevator_entities = Vec::new();
536            for ec in &lc.elevators {
537                let eid = Self::spawn_elevator_entity(
538                    world,
539                    ec,
540                    line_eid,
541                    stop_lookup,
542                    &config.building.stops,
543                );
544                elevator_entities.push(eid);
545            }
546
547            let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
548            line_map.insert(lc.id, (line_eid, line_info));
549        }
550
551        // Build groups from GroupConfigs, or auto-infer a single group.
552        let group_configs = config.building.groups.as_deref();
553        let mut groups = Vec::new();
554        let mut dispatchers = BTreeMap::new();
555        let mut strategy_ids = BTreeMap::new();
556
557        if let Some(gcs) = group_configs {
558            for gc in gcs {
559                let group_id = GroupId(gc.id);
560
561                let mut group_lines = Vec::new();
562
563                for &lid in &gc.lines {
564                    if let Some((line_eid, li)) = line_map.get(&lid) {
565                        // Update the line's group assignment.
566                        if let Some(line_comp) = world.line_mut(*line_eid) {
567                            line_comp.group = group_id;
568                        }
569                        group_lines.push(li.clone());
570                    }
571                }
572
573                let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
574                if let Some(mode) = gc.hall_call_mode {
575                    group.set_hall_call_mode(mode);
576                }
577                if let Some(ticks) = gc.ack_latency_ticks {
578                    group.set_ack_latency_ticks(ticks);
579                }
580                groups.push(group);
581
582                // GroupConfig strategy; builder overrides applied after this loop.
583                let dispatch: Box<dyn DispatchStrategy> = gc
584                    .dispatch
585                    .instantiate()
586                    .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
587                dispatchers.insert(group_id, dispatch);
588                strategy_ids.insert(group_id, gc.dispatch.clone());
589            }
590        } else {
591            // No explicit groups — create a single default group with all lines.
592            let group_id = GroupId(0);
593            let mut group_lines = Vec::new();
594
595            for (line_eid, li) in line_map.values() {
596                if let Some(line_comp) = world.line_mut(*line_eid) {
597                    line_comp.group = group_id;
598                }
599                group_lines.push(li.clone());
600            }
601
602            let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
603            groups.push(group);
604
605            let dispatch: Box<dyn DispatchStrategy> =
606                Box::new(crate::dispatch::scan::ScanDispatch::new());
607            dispatchers.insert(group_id, dispatch);
608            strategy_ids.insert(group_id, BuiltinStrategy::Scan);
609        }
610
611        // Builder-provided dispatchers override the config. For the matching
612        // `strategy_ids` entry, prefer the dispatcher's own `builtin_id()`
613        // (snapshot fidelity); fall back to the config id only when the
614        // dispatcher is unidentified (custom strategies that don't override).
615        for (gid, d) in builder_dispatchers {
616            let inferred_id = d.builtin_id();
617            dispatchers.insert(gid, d);
618            match inferred_id {
619                Some(id) => {
620                    strategy_ids.insert(gid, id);
621                }
622                None => {
623                    strategy_ids
624                        .entry(gid)
625                        .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
626                }
627            }
628        }
629
630        (groups, dispatchers, strategy_ids)
631    }
632
633    /// Restore a simulation from pre-built parts (used by snapshot restore).
634    #[allow(clippy::too_many_arguments)]
635    pub(crate) fn from_parts(
636        world: World,
637        tick: u64,
638        dt: f64,
639        groups: Vec<ElevatorGroup>,
640        stop_lookup: HashMap<StopId, EntityId>,
641        dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
642        strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
643        metrics: Metrics,
644        ticks_per_second: f64,
645    ) -> Self {
646        let mut rider_index = RiderIndex::default();
647        rider_index.rebuild(&world);
648        // Forward-compat: snapshots predating these resources won't carry
649        // them. `TickRate` would otherwise default to 60 Hz and silently
650        // halve ETD's door-cost scale on a 30 Hz sim; the traffic detector
651        // would no-op forever in the metrics phase. `insert_resource` is
652        // last-writer-wins, so snapshots that already carry them are kept.
653        let mut world = world;
654        world.insert_resource(crate::time::TickRate(ticks_per_second));
655        if world
656            .resource::<crate::traffic_detector::TrafficDetector>()
657            .is_none()
658        {
659            world.insert_resource(crate::traffic_detector::TrafficDetector::default());
660        }
661        // Same forward-compat pattern for the destination log. An
662        // older snapshot would leave the detector unable to detect
663        // down-peak post-restore; a fresh empty log lets it resume
664        // classification after a few ticks of observed traffic.
665        if world
666            .resource::<crate::arrival_log::DestinationLog>()
667            .is_none()
668        {
669            world.insert_resource(crate::arrival_log::DestinationLog::default());
670        }
671        // Auto-register dispatch-internal extension types the sim itself
672        // owns, and immediately load their data from the pending
673        // resource. Without this, DCS sticky assignments
674        // (`AssignedCar`) evaporate across snapshot round-trip and
675        // `DestinationDispatch` re-computes every commitment from
676        // scratch — producing different decisions than the original
677        // sim and breaking tick-for-tick determinism.
678        //
679        // `deserialize_extensions` takes a `&` of the pending map and
680        // silently skips types that aren't registered, so the call is
681        // safe to make with user-owned extensions still in the map.
682        // The `PendingExtensions` resource stays in place for a later
683        // `load_extensions_with` call to materialize the caller's own
684        // types.
685        world.register_ext::<crate::dispatch::destination::AssignedCar>(
686            crate::dispatch::destination::ASSIGNED_CAR_KEY,
687        );
688        if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
689            let data = pending.0.clone();
690            world.deserialize_extensions(&data);
691        }
692        Self {
693            world,
694            events: EventBus::default(),
695            pending_output: Vec::new(),
696            tick,
697            dt,
698            groups,
699            stop_lookup,
700            dispatchers,
701            strategy_ids,
702            repositioners: BTreeMap::new(),
703            reposition_ids: BTreeMap::new(),
704            metrics,
705            time: TimeAdapter::new(ticks_per_second),
706            hooks: PhaseHooks::default(),
707            elevator_ids_buf: Vec::new(),
708            reposition_buf: Vec::new(),
709            dispatch_scratch: crate::dispatch::DispatchScratch::default(),
710            topo_graph: Mutex::new(TopologyGraph::new()),
711            rider_index,
712            tick_in_progress: false,
713        }
714    }
715
716    /// Validate configuration before constructing the simulation.
717    pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
718        // Schema-version gate: reject forward-incompatible configs (a
719        // future build's RON would silently mis-deserialize fields a
720        // current build doesn't know about) and surface legacy
721        // pre-versioning configs (`schema_version = 0`) as an explicit
722        // upgrade prompt rather than a silent serde-default smear. See
723        // `docs/src/config-versioning.md` for the migration playbook.
724        if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
725            return Err(SimError::InvalidConfig {
726                field: "schema_version",
727                reason: format!(
728                    "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
729                    config.schema_version,
730                    crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
731                ),
732            });
733        }
734        if config.schema_version == 0 {
735            return Err(SimError::InvalidConfig {
736                field: "schema_version",
737                reason: format!(
738                    "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
739                    crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
740                ),
741            });
742        }
743
744        if config.building.stops.is_empty() {
745            return Err(SimError::InvalidConfig {
746                field: "building.stops",
747                reason: "at least one stop is required".into(),
748            });
749        }
750
751        // Check for duplicate stop IDs and validate positions.
752        let mut seen_ids = HashSet::new();
753        for stop in &config.building.stops {
754            if !seen_ids.insert(stop.id) {
755                return Err(SimError::InvalidConfig {
756                    field: "building.stops",
757                    reason: format!("duplicate {}", stop.id),
758                });
759            }
760            if !stop.position.is_finite() {
761                return Err(SimError::InvalidConfig {
762                    field: "building.stops.position",
763                    reason: format!("{} has non-finite position {}", stop.id, stop.position),
764                });
765            }
766        }
767
768        let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
769
770        if let Some(line_configs) = &config.building.lines {
771            // ── Explicit topology validation ──
772            Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
773        } else {
774            // ── Legacy flat elevator list validation ──
775            Self::validate_legacy_elevators(&config.elevators, &config.building)?;
776        }
777
778        if !config.simulation.ticks_per_second.is_finite()
779            || config.simulation.ticks_per_second <= 0.0
780        {
781            return Err(SimError::InvalidConfig {
782                field: "simulation.ticks_per_second",
783                reason: format!(
784                    "must be finite and positive, got {}",
785                    config.simulation.ticks_per_second
786                ),
787            });
788        }
789
790        Self::validate_passenger_spawning(&config.passenger_spawning)?;
791
792        Ok(())
793    }
794
795    /// Validate `PassengerSpawnConfig`. Without this, bad inputs reach
796    /// `PoissonSource::from_config` and panic later (NaN/negative weights
797    /// crash `random_range`/`Weight::from`; zero `mean_interval_ticks`
798    /// burst-fires every catch-up tick). (#272)
799    fn validate_passenger_spawning(
800        spawn: &crate::config::PassengerSpawnConfig,
801    ) -> Result<(), SimError> {
802        let (lo, hi) = spawn.weight_range;
803        if !lo.is_finite() || !hi.is_finite() {
804            return Err(SimError::InvalidConfig {
805                field: "passenger_spawning.weight_range",
806                reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
807            });
808        }
809        if lo < 0.0 || hi < 0.0 {
810            return Err(SimError::InvalidConfig {
811                field: "passenger_spawning.weight_range",
812                reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
813            });
814        }
815        if lo > hi {
816            return Err(SimError::InvalidConfig {
817                field: "passenger_spawning.weight_range",
818                reason: format!("min must be <= max, got ({lo}, {hi})"),
819            });
820        }
821        if spawn.mean_interval_ticks == 0 {
822            return Err(SimError::InvalidConfig {
823                field: "passenger_spawning.mean_interval_ticks",
824                reason: "must be > 0; mean_interval_ticks=0 burst-fires \
825                         every catch-up tick"
826                    .into(),
827            });
828        }
829        Ok(())
830    }
831
832    /// Validate the legacy flat elevator list.
833    fn validate_legacy_elevators(
834        elevators: &[crate::config::ElevatorConfig],
835        building: &crate::config::BuildingConfig,
836    ) -> Result<(), SimError> {
837        if elevators.is_empty() {
838            return Err(SimError::InvalidConfig {
839                field: "elevators",
840                reason: "at least one elevator is required".into(),
841            });
842        }
843
844        for elev in elevators {
845            Self::validate_elevator_config(elev, building)?;
846        }
847
848        Ok(())
849    }
850
851    /// Validate a single elevator config's physics and starting stop.
852    fn validate_elevator_config(
853        elev: &crate::config::ElevatorConfig,
854        building: &crate::config::BuildingConfig,
855    ) -> Result<(), SimError> {
856        validate_elevator_physics(
857            elev.max_speed.value(),
858            elev.acceleration.value(),
859            elev.deceleration.value(),
860            elev.weight_capacity.value(),
861            elev.inspection_speed_factor,
862            elev.door_transition_ticks,
863            elev.door_open_ticks,
864            elev.bypass_load_up_pct,
865            elev.bypass_load_down_pct,
866        )?;
867        if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
868            return Err(SimError::InvalidConfig {
869                field: "elevators.starting_stop",
870                reason: format!("references non-existent {}", elev.starting_stop),
871            });
872        }
873        Ok(())
874    }
875
876    /// Validate explicit line/group topology.
877    fn validate_explicit_topology(
878        line_configs: &[crate::config::LineConfig],
879        stop_ids: &HashSet<StopId>,
880        building: &crate::config::BuildingConfig,
881    ) -> Result<(), SimError> {
882        // No duplicate line IDs.
883        let mut seen_line_ids = HashSet::new();
884        for lc in line_configs {
885            if !seen_line_ids.insert(lc.id) {
886                return Err(SimError::InvalidConfig {
887                    field: "building.lines",
888                    reason: format!("duplicate line id {}", lc.id),
889                });
890            }
891        }
892
893        // Every line's serves must reference existing stops and be non-empty.
894        for lc in line_configs {
895            if lc.serves.is_empty() {
896                return Err(SimError::InvalidConfig {
897                    field: "building.lines.serves",
898                    reason: format!("line {} has no stops", lc.id),
899                });
900            }
901            for sid in &lc.serves {
902                if !stop_ids.contains(sid) {
903                    return Err(SimError::InvalidConfig {
904                        field: "building.lines.serves",
905                        reason: format!("line {} references non-existent {}", lc.id, sid),
906                    });
907                }
908            }
909            // Validate elevators within each line.
910            for ec in &lc.elevators {
911                Self::validate_elevator_config(ec, building)?;
912            }
913
914            // Validate max_cars is not exceeded.
915            if let Some(max) = lc.max_cars
916                && lc.elevators.len() > max
917            {
918                return Err(SimError::InvalidConfig {
919                    field: "building.lines.max_cars",
920                    reason: format!(
921                        "line {} has {} elevators but max_cars is {max}",
922                        lc.id,
923                        lc.elevators.len()
924                    ),
925                });
926            }
927        }
928
929        // At least one line with at least one elevator.
930        let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
931        if !has_elevator {
932            return Err(SimError::InvalidConfig {
933                field: "building.lines",
934                reason: "at least one line must have at least one elevator".into(),
935            });
936        }
937
938        // No orphaned stops: every stop must be served by at least one line.
939        let served: HashSet<StopId> = line_configs
940            .iter()
941            .flat_map(|lc| lc.serves.iter().copied())
942            .collect();
943        for sid in stop_ids {
944            if !served.contains(sid) {
945                return Err(SimError::InvalidConfig {
946                    field: "building.lines",
947                    reason: format!("orphaned stop {sid} not served by any line"),
948                });
949            }
950        }
951
952        // Validate groups if present.
953        if let Some(group_configs) = &building.groups {
954            let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
955
956            let mut seen_group_ids = HashSet::new();
957            for gc in group_configs {
958                if !seen_group_ids.insert(gc.id) {
959                    return Err(SimError::InvalidConfig {
960                        field: "building.groups",
961                        reason: format!("duplicate group id {}", gc.id),
962                    });
963                }
964                for &lid in &gc.lines {
965                    if !line_id_set.contains(&lid) {
966                        return Err(SimError::InvalidConfig {
967                            field: "building.groups.lines",
968                            reason: format!(
969                                "group {} references non-existent line id {}",
970                                gc.id, lid
971                            ),
972                        });
973                    }
974                }
975            }
976
977            // Check for orphaned lines (not referenced by any group).
978            let referenced_line_ids: HashSet<u32> = group_configs
979                .iter()
980                .flat_map(|g| g.lines.iter().copied())
981                .collect();
982            for lc in line_configs {
983                if !referenced_line_ids.contains(&lc.id) {
984                    return Err(SimError::InvalidConfig {
985                        field: "building.lines",
986                        reason: format!("line {} is not assigned to any group", lc.id),
987                    });
988                }
989            }
990        }
991
992        Ok(())
993    }
994
995    // ── Dispatch management ──────────────────────────────────────────
996
997    /// Replace the dispatch strategy for a group.
998    ///
999    /// Also synchronises `HallCallMode`: `Destination` for DCS, `Classic`
1000    /// for other built-ins; `Custom` strategies leave the mode untouched.
1001    ///
1002    /// The stored snapshot identity is taken from the strategy's own
1003    /// [`DispatchStrategy::builtin_id`] when it returns `Some(..)`, so
1004    /// built-in strategies always round-trip as themselves even if the
1005    /// `id` argument drifts out of sync with the actual impl. Custom
1006    /// strategies that don't override `builtin_id` fall back to the
1007    /// caller-supplied `id`, preserving the prior API for registered
1008    /// custom factories. Mirrors the pattern applied to
1009    /// [`set_reposition`](Self::set_reposition) in #414.
1010    pub fn set_dispatch(
1011        &mut self,
1012        group: GroupId,
1013        strategy: Box<dyn DispatchStrategy>,
1014        id: crate::dispatch::BuiltinStrategy,
1015    ) {
1016        let resolved_id = strategy.builtin_id().unwrap_or(id);
1017        let mode = match &resolved_id {
1018            BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
1019            BuiltinStrategy::Custom(_) => None,
1020            BuiltinStrategy::Scan
1021            | BuiltinStrategy::Look
1022            | BuiltinStrategy::NearestCar
1023            | BuiltinStrategy::Etd
1024            | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
1025        };
1026        if let Some(mode) = mode
1027            && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1028        {
1029            g.set_hall_call_mode(mode);
1030        }
1031        self.dispatchers.insert(group, strategy);
1032        self.strategy_ids.insert(group, resolved_id);
1033    }
1034
1035    // ── Reposition management ─────────────────────────────────────────
1036
1037    /// Set the reposition strategy for a group.
1038    ///
1039    /// Enables the reposition phase for this group. Idle elevators will
1040    /// be repositioned according to the strategy after each dispatch phase.
1041    ///
1042    /// The stored snapshot identity is taken from the strategy's own
1043    /// [`RepositionStrategy::builtin_id`] when it returns `Some(..)`,
1044    /// so built-in strategies always round-trip as themselves even if
1045    /// the `id` argument drifts out of sync with the actual impl.
1046    /// Custom strategies that don't override `builtin_id` fall back
1047    /// to the caller-supplied `id`, preserving the prior API for
1048    /// registered custom factories.
1049    ///
1050    /// ## Retention
1051    /// Widens [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1052    /// to the strategy's
1053    /// [`min_arrival_log_window`](crate::dispatch::RepositionStrategy::min_arrival_log_window)
1054    /// when that exceeds current retention, never narrows it. This is
1055    /// monotonic by design — replacing a wide-window strategy with a
1056    /// narrow one (or [`remove_reposition`](Self::remove_reposition))
1057    /// leaves retention at the high-water mark rather than recomputing
1058    /// across the remaining strategies, since shrinking would also
1059    /// clobber any explicit
1060    /// [`set_arrival_log_retention_ticks`](Self::set_arrival_log_retention_ticks)
1061    /// the caller made afterwards. Long-running sims that hot-swap
1062    /// strategies pay a memory cost equal to the largest historic
1063    /// window; if that matters, call `set_arrival_log_retention_ticks`
1064    /// explicitly after the swap.
1065    pub fn set_reposition(
1066        &mut self,
1067        group: GroupId,
1068        strategy: Box<dyn RepositionStrategy>,
1069        id: BuiltinReposition,
1070    ) {
1071        let resolved_id = strategy.builtin_id().unwrap_or(id);
1072        let needed_window = strategy.min_arrival_log_window();
1073        self.repositioners.insert(group, strategy);
1074        self.reposition_ids.insert(group, resolved_id);
1075        // Widen the arrival-log retention if the freshly installed
1076        // strategy queries a window the pruner would otherwise truncate
1077        // under it. Without this, `PredictiveParking::with_window_ticks`
1078        // (or any custom strategy advertising a longer window) silently
1079        // sees only the last `DEFAULT_ARRIVAL_WINDOW_TICKS` of arrivals.
1080        if needed_window > 0
1081            && let Some(retention) = self
1082                .world
1083                .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1084            && needed_window > retention.0
1085        {
1086            retention.0 = needed_window;
1087        }
1088    }
1089
1090    /// Remove the reposition strategy for a group, disabling repositioning.
1091    ///
1092    /// Does not narrow
1093    /// [`ArrivalLogRetention`](crate::arrival_log::ArrivalLogRetention)
1094    /// — see the retention note on
1095    /// [`set_reposition`](Self::set_reposition) for why retention is
1096    /// monotonic across strategy lifecycle changes. Call
1097    /// [`set_arrival_log_retention_ticks`](Self::set_arrival_log_retention_ticks)
1098    /// explicitly to shrink retention after removing a wide-window
1099    /// strategy.
1100    pub fn remove_reposition(&mut self, group: GroupId) {
1101        self.repositioners.remove(&group);
1102        self.reposition_ids.remove(&group);
1103    }
1104
1105    /// Get the reposition strategy identifier for a group.
1106    #[must_use]
1107    pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1108        self.reposition_ids.get(&group)
1109    }
1110
1111    // ── Hooks ────────────────────────────────────────────────────────
1112
1113    /// Register a hook to run before a simulation phase.
1114    ///
1115    /// Hooks are called in registration order. The hook receives mutable
1116    /// access to the world, allowing entity inspection or modification.
1117    pub fn add_before_hook(
1118        &mut self,
1119        phase: Phase,
1120        hook: impl Fn(&mut World) + Send + Sync + 'static,
1121    ) {
1122        self.hooks.add_before(phase, Box::new(hook));
1123    }
1124
1125    /// Register a hook to run after a simulation phase.
1126    ///
1127    /// Hooks are called in registration order. The hook receives mutable
1128    /// access to the world, allowing entity inspection or modification.
1129    pub fn add_after_hook(
1130        &mut self,
1131        phase: Phase,
1132        hook: impl Fn(&mut World) + Send + Sync + 'static,
1133    ) {
1134        self.hooks.add_after(phase, Box::new(hook));
1135    }
1136
1137    /// Register a hook to run before a phase for a specific group.
1138    pub fn add_before_group_hook(
1139        &mut self,
1140        phase: Phase,
1141        group: GroupId,
1142        hook: impl Fn(&mut World) + Send + Sync + 'static,
1143    ) {
1144        self.hooks.add_before_group(phase, group, Box::new(hook));
1145    }
1146
1147    /// Register a hook to run after a phase for a specific group.
1148    pub fn add_after_group_hook(
1149        &mut self,
1150        phase: Phase,
1151        group: GroupId,
1152        hook: impl Fn(&mut World) + Send + Sync + 'static,
1153    ) {
1154        self.hooks.add_after_group(phase, group, Box::new(hook));
1155    }
1156}