Skip to main content

elevator_core/
traffic.rs

1//! Traffic generation for rider arrivals.
2//!
3//! This module provides:
4//!
5//! - [`TrafficPattern`](crate::traffic::TrafficPattern) — origin/destination distribution
6//!   presets (up-peak, down-peak, etc.).
7//! - [`TrafficSchedule`](crate::traffic::TrafficSchedule) — time-varying pattern selection
8//!   across a simulated day.
9//! - [`TrafficSource`](crate::traffic::TrafficSource) — trait for external traffic
10//!   generators that feed riders into a [`Simulation`](crate::sim::Simulation) each tick.
11//! - [`PoissonSource`](crate::traffic::PoissonSource) — Poisson-arrival traffic generator
12//!   using schedules and spawn config.
13//! - [`SpawnRequest`](crate::traffic::SpawnRequest) — a single rider spawn instruction
14//!   returned by a traffic source.
15//!
16//! # Design
17//!
18//! Traffic generation is **external to the simulation loop**. A
19//! [`TrafficSource`](crate::traffic::TrafficSource) produces
20//! [`SpawnRequest`](crate::traffic::SpawnRequest)s each tick; the consumer feeds them into
21//! [`Simulation::spawn_rider`](crate::sim::Simulation::spawn_rider)
22//! (or the [`RiderBuilder`](crate::sim::RiderBuilder) for richer configuration).
23//!
24//! ```rust,no_run
25//! use elevator_core::prelude::*;
26//! use elevator_core::config::SimConfig;
27//! use elevator_core::traffic::{PoissonSource, TrafficSource};
28//! # fn run(config: &SimConfig) -> Result<(), SimError> {
29//! let mut sim = SimulationBuilder::from_config(config.clone()).build()?;
30//! let mut source = PoissonSource::from_config(config);
31//!
32//! for _ in 0..10_000 {
33//!     let tick = sim.current_tick();
34//!     for req in source.generate(tick) {
35//!         let _ = sim.spawn_rider(req.origin, req.destination, req.weight);
36//!     }
37//!     sim.step();
38//! }
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::config::SimConfig;
44use crate::entity::EntityId;
45use crate::stop::StopId;
46use rand::RngExt;
47use serde::{Deserialize, Serialize};
48
49// ── TrafficPattern ───────────────────────────────────────────────────
50
51/// Traffic pattern for generating realistic rider origin/destination distributions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[non_exhaustive]
54pub enum TrafficPattern {
55    /// Uniform random: equal probability for all origin/destination pairs.
56    Uniform,
57    /// Morning rush: most riders originate from the lobby (first stop) going up.
58    UpPeak,
59    /// Evening rush: most riders head to the lobby (first stop) from upper stops.
60    DownPeak,
61    /// Lunch rush: riders go from upper stops to a mid-range stop and back.
62    Lunchtime,
63    /// Mixed: combination of up-peak, down-peak, and inter-floor traffic.
64    Mixed,
65    /// Stranded top-floor: most riders originate at the **topmost** stop
66    /// (rather than the lobby) and head elsewhere. Models the canonical
67    /// community-benchmark shape where a penthouse or rooftop deck
68    /// drives isolated, low-rate demand that easily gets starved by
69    /// lobby-centric dispatchers. Unlike [`UpPeak`](Self::UpPeak),
70    /// origins are skewed *away* from the lobby.
71    TopFloorPeak,
72}
73
74/// Sample an (origin, destination) index pair from `n` stops.
75///
76/// Returns indices into the stops slice. All pattern logic lives here;
77/// public methods just map indices to their concrete ID types.
78fn sample_indices(
79    pattern: TrafficPattern,
80    n: usize,
81    rng: &mut impl RngExt,
82) -> Option<(usize, usize)> {
83    if n < 2 {
84        return None;
85    }
86
87    let lobby = 0;
88    // Lower-middle index. For even `n`, `n/2` equals `n.div_ceil(2)` (the
89    // upper-half start used by `Lunchtime`), making the two ranges overlap
90    // and silently disabling the bias. `(n - 1) / 2` keeps `mid` strictly
91    // below `upper_start` for even n while preserving the same value for
92    // odd n. (#269)
93    let mid = (n - 1) / 2;
94
95    match pattern {
96        TrafficPattern::Uniform => Some(uniform_pair_indices(n, rng)),
97
98        TrafficPattern::UpPeak => {
99            // 80% from lobby, 20% inter-floor.
100            if rng.random_range(0.0..1.0) < 0.8 {
101                Some((lobby, rng.random_range(1..n)))
102            } else {
103                Some(uniform_pair_indices(n, rng))
104            }
105        }
106
107        TrafficPattern::DownPeak => {
108            // 80% heading to lobby, 20% inter-floor.
109            if rng.random_range(0.0..1.0) < 0.8 {
110                Some((rng.random_range(1..n), lobby))
111            } else {
112                Some(uniform_pair_indices(n, rng))
113            }
114        }
115
116        TrafficPattern::Lunchtime => {
117            // 40% upper→mid, 40% mid→upper, 20% random.
118            if n < 2 {
119                return Some(uniform_pair_indices(n, rng));
120            }
121            let r: f64 = rng.random_range(0.0..1.0);
122            let upper_start = n.div_ceil(2);
123            if r < 0.4 && upper_start < n && upper_start != mid {
124                Some((rng.random_range(upper_start..n), mid))
125            } else if r < 0.8 && upper_start < n && upper_start != mid {
126                Some((mid, rng.random_range(upper_start..n)))
127            } else {
128                Some(uniform_pair_indices(n, rng))
129            }
130        }
131
132        TrafficPattern::Mixed => {
133            // 30% up-peak, 30% down-peak, 40% inter-floor.
134            let r: f64 = rng.random_range(0.0..1.0);
135            if r < 0.3 {
136                Some((lobby, rng.random_range(1..n)))
137            } else if r < 0.6 {
138                Some((rng.random_range(1..n), lobby))
139            } else {
140                Some(uniform_pair_indices(n, rng))
141            }
142        }
143
144        TrafficPattern::TopFloorPeak => {
145            // 80% from the top stop, 20% inter-floor. Destination is
146            // drawn from `0..top` so origin ≠ destination without
147            // needing the rejection-sampling loop in
148            // `uniform_pair_indices`.
149            let top = n - 1;
150            if rng.random_range(0.0..1.0) < 0.8 {
151                Some((top, rng.random_range(0..top)))
152            } else {
153                Some(uniform_pair_indices(n, rng))
154            }
155        }
156    }
157}
158
159/// Pick two distinct random indices from `0..n`.
160fn uniform_pair_indices(n: usize, rng: &mut impl RngExt) -> (usize, usize) {
161    let o = rng.random_range(0..n);
162    let mut d = rng.random_range(0..n);
163    while d == o {
164        d = rng.random_range(0..n);
165    }
166    (o, d)
167}
168
169impl TrafficPattern {
170    /// Sample an (origin, destination) pair from the given stops.
171    ///
172    /// `stops` must be sorted by position (lowest first). The first stop
173    /// is treated as the "lobby" for peak patterns.
174    ///
175    /// Returns `None` if fewer than 2 stops are provided.
176    pub fn sample(
177        &self,
178        stops: &[EntityId],
179        rng: &mut impl RngExt,
180    ) -> Option<(EntityId, EntityId)> {
181        let (o, d) = sample_indices(*self, stops.len(), rng)?;
182        Some((stops[o], stops[d]))
183    }
184
185    /// Sample an (origin, destination) pair using config [`StopId`]s.
186    ///
187    /// Same as [`sample`](Self::sample) but works with `StopId` slices for
188    /// use outside the simulation (no `EntityId` resolution needed).
189    pub fn sample_stop_ids(
190        &self,
191        stops: &[StopId],
192        rng: &mut impl RngExt,
193    ) -> Option<(StopId, StopId)> {
194        let (o, d) = sample_indices(*self, stops.len(), rng)?;
195        Some((stops[o], stops[d]))
196    }
197}
198
199// ── TrafficSchedule ──────────────────────────────────────────────────
200
201/// A time-varying traffic schedule that selects patterns based on tick count.
202///
203/// Maps tick ranges to traffic patterns, enabling realistic daily cycles
204/// (e.g., up-peak in the morning, lunchtime at noon, down-peak in evening).
205///
206/// # Example
207///
208/// ```rust,no_run
209/// use elevator_core::prelude::*;
210/// use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
211/// use rand::{SeedableRng, rngs::StdRng};
212///
213/// let schedule = TrafficSchedule::new(vec![
214///     (0..3600, TrafficPattern::UpPeak),      // First hour: morning rush
215///     (3600..7200, TrafficPattern::Uniform),   // Second hour: normal
216///     (7200..10800, TrafficPattern::Lunchtime), // Third hour: lunch
217///     (10800..14400, TrafficPattern::DownPeak), // Fourth hour: evening rush
218/// ]);
219///
220/// // Sampling uses the pattern active at the given tick
221/// let stops = vec![StopId(0), StopId(1)];
222/// let mut rng = StdRng::seed_from_u64(0);
223/// let tick: u64 = 0;
224/// let (origin, dest) = schedule.sample_stop_ids(tick, &stops, &mut rng).unwrap();
225/// # let _ = (origin, dest);
226/// ```
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct TrafficSchedule {
229    /// Tick ranges mapped to traffic patterns, in order.
230    segments: Vec<(std::ops::Range<u64>, TrafficPattern)>,
231    /// Pattern to use when tick falls outside all segments.
232    fallback: TrafficPattern,
233}
234
235impl TrafficSchedule {
236    /// Create a schedule from segments.
237    ///
238    /// Segments are `(tick_range, pattern)` pairs. If the current tick
239    /// doesn't fall within any segment, the fallback `Uniform` pattern is used.
240    #[must_use]
241    pub const fn new(segments: Vec<(std::ops::Range<u64>, TrafficPattern)>) -> Self {
242        Self {
243            segments,
244            fallback: TrafficPattern::Uniform,
245        }
246    }
247
248    /// Set the fallback pattern for ticks outside all segments.
249    #[must_use]
250    pub const fn with_fallback(mut self, pattern: TrafficPattern) -> Self {
251        self.fallback = pattern;
252        self
253    }
254
255    /// Get the active traffic pattern for the given tick.
256    #[must_use]
257    pub fn pattern_at(&self, tick: u64) -> &TrafficPattern {
258        self.segments
259            .iter()
260            .find(|(range, _)| range.contains(&tick))
261            .map_or(&self.fallback, |(_, pattern)| pattern)
262    }
263
264    /// Sample an (origin, destination) pair using the pattern active at `tick`.
265    ///
266    /// Delegates to [`TrafficPattern::sample()`] for the active pattern.
267    pub fn sample(
268        &self,
269        tick: u64,
270        stops: &[EntityId],
271        rng: &mut impl RngExt,
272    ) -> Option<(EntityId, EntityId)> {
273        self.pattern_at(tick).sample(stops, rng)
274    }
275
276    /// Sample an (origin, destination) pair by [`StopId`] using the active pattern.
277    pub fn sample_stop_ids(
278        &self,
279        tick: u64,
280        stops: &[StopId],
281        rng: &mut impl RngExt,
282    ) -> Option<(StopId, StopId)> {
283        self.pattern_at(tick).sample_stop_ids(stops, rng)
284    }
285
286    /// Create a typical office-building daily schedule.
287    ///
288    /// Assumes `ticks_per_hour` ticks per real-world hour:
289    /// - Hours 0-1: Up-peak (morning rush)
290    /// - Hours 1-4: Uniform (normal traffic)
291    /// - Hours 4-5: Lunchtime
292    /// - Hours 5-8: Uniform (afternoon)
293    /// - Hours 8-9: Down-peak (evening rush)
294    /// - Hours 9+: Uniform (fallback)
295    #[must_use]
296    pub fn office_day(ticks_per_hour: u64) -> Self {
297        Self::new(vec![
298            (0..ticks_per_hour, TrafficPattern::UpPeak),
299            (ticks_per_hour..4 * ticks_per_hour, TrafficPattern::Uniform),
300            (
301                4 * ticks_per_hour..5 * ticks_per_hour,
302                TrafficPattern::Lunchtime,
303            ),
304            (
305                5 * ticks_per_hour..8 * ticks_per_hour,
306                TrafficPattern::Uniform,
307            ),
308            (
309                8 * ticks_per_hour..9 * ticks_per_hour,
310                TrafficPattern::DownPeak,
311            ),
312        ])
313    }
314
315    /// Create a constant schedule that uses the same pattern for all ticks.
316    #[must_use]
317    pub const fn constant(pattern: TrafficPattern) -> Self {
318        Self {
319            segments: Vec::new(),
320            fallback: pattern,
321        }
322    }
323}
324
325// ── TrafficSource + SpawnRequest ─────────────────────────────────────
326
327/// A request to spawn a single rider, produced by a [`TrafficSource`].
328///
329/// Feed these into [`Simulation::spawn_rider`](crate::sim::Simulation::spawn_rider)
330/// or the [`RiderBuilder`](crate::sim::RiderBuilder) each tick.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct SpawnRequest {
333    /// Origin stop (config ID).
334    pub origin: StopId,
335    /// Destination stop (config ID).
336    pub destination: StopId,
337    /// Rider weight.
338    pub weight: f64,
339}
340
341/// Trait for external traffic generators.
342///
343/// Implementors produce zero or more [`SpawnRequest`]s per tick. The consumer
344/// is responsible for feeding them into the simulation:
345///
346/// ```rust,no_run
347/// # use elevator_core::prelude::*;
348/// # use elevator_core::traffic::TrafficSource;
349/// # fn run(sim: &mut Simulation, source: &mut impl TrafficSource, tick: u64) -> Result<(), SimError> {
350/// for req in source.generate(tick) {
351///     sim.spawn_rider(req.origin, req.destination, req.weight)?;
352/// }
353/// # Ok(())
354/// # }
355/// ```
356///
357/// This design keeps traffic generation external to the simulation loop,
358/// giving consumers full control over when and how riders are spawned.
359pub trait TrafficSource {
360    /// Generate spawn requests for the given tick.
361    ///
362    /// May return an empty vec (no arrivals this tick) or multiple requests
363    /// (burst arrivals). The implementation controls the arrival process.
364    fn generate(&mut self, tick: u64) -> Vec<SpawnRequest>;
365}
366
367// ── PoissonSource ────────────────────────────────────────────────────
368
369/// Poisson-arrival traffic generator with time-varying patterns.
370///
371/// Uses an exponential inter-arrival time model: each tick, the generator
372/// checks whether enough time has elapsed since the last spawn. The mean
373/// interval comes from
374/// [`PassengerSpawnConfig::mean_interval_ticks`](crate::config::PassengerSpawnConfig::mean_interval_ticks).
375///
376/// Origin/destination pairs are sampled from a [`TrafficSchedule`] that
377/// selects the active [`TrafficPattern`] based on the current tick.
378///
379/// # Example
380///
381/// ```rust,no_run
382/// use elevator_core::prelude::*;
383/// use elevator_core::config::SimConfig;
384/// use elevator_core::traffic::{PoissonSource, TrafficSchedule};
385///
386/// # fn run(config: &SimConfig) {
387/// // From a SimConfig (reads stops and spawn parameters).
388/// let mut source = PoissonSource::from_config(config);
389///
390/// // Or build manually.
391/// let stops = vec![StopId(0), StopId(1)];
392/// let mut source = PoissonSource::new(
393///     stops,
394///     TrafficSchedule::office_day(3600),
395///     120,           // mean_interval_ticks
396///     (60.0, 90.0),  // weight_range
397/// );
398/// # let _ = source;
399/// # }
400/// ```
401pub struct PoissonSource {
402    /// Sorted stop IDs (lowest position first).
403    stops: Vec<StopId>,
404    /// Time-varying pattern schedule.
405    schedule: TrafficSchedule,
406    /// Mean ticks between arrivals (lambda = 1/mean).
407    mean_interval: u32,
408    /// Weight range `(min, max)` for spawned riders.
409    weight_range: (f64, f64),
410    /// RNG for sampling. Defaults to an OS-seeded [`rand::rngs::StdRng`];
411    /// swap in a user-seeded RNG via [`Self::with_rng`] for deterministic
412    /// traffic.
413    rng: rand::rngs::StdRng,
414    /// Tick of the next scheduled arrival.
415    next_arrival_tick: u64,
416    /// True once the schedule is "committed" — either `generate()` has
417    /// processed at least one tick, or `with_mean_interval()` has been
418    /// called. Used by [`Self::with_rng`] to decide whether to reset the
419    /// anchor (so the new RNG drives sampling from tick 0) or keep the
420    /// current anchor (so a mid-simulation RNG swap does not rewind).
421    rng_committed: bool,
422}
423
424impl PoissonSource {
425    /// Create a new Poisson traffic source.
426    ///
427    /// `stops` should be sorted by position (lowest first) to match
428    /// [`TrafficPattern`] expectations (first stop = lobby).
429    ///
430    /// If `weight_range.0 > weight_range.1`, the values are swapped.
431    #[must_use]
432    pub fn new(
433        stops: Vec<StopId>,
434        schedule: TrafficSchedule,
435        mean_interval_ticks: u32,
436        weight_range: (f64, f64),
437    ) -> Self {
438        let weight_range = if weight_range.0 > weight_range.1 {
439            (weight_range.1, weight_range.0)
440        } else {
441            weight_range
442        };
443        let mut rng = rand::make_rng::<rand::rngs::StdRng>();
444        let next = sample_next_arrival(0, mean_interval_ticks, &mut rng);
445        Self {
446            stops,
447            schedule,
448            mean_interval: mean_interval_ticks,
449            weight_range,
450            rng,
451            next_arrival_tick: next,
452            rng_committed: false,
453        }
454    }
455
456    /// Create a Poisson source from a [`SimConfig`].
457    ///
458    /// Reads stop IDs from the building config and spawn parameters from
459    /// `passenger_spawning`. Uses a constant [`TrafficPattern::Uniform`] schedule
460    /// by default — call [`with_schedule`](Self::with_schedule) to override.
461    #[must_use]
462    pub fn from_config(config: &SimConfig) -> Self {
463        // Sort by position so stops[0] is the lobby (lowest position),
464        // matching TrafficPattern's assumption.
465        let mut stop_entries: Vec<_> = config.building.stops.iter().collect();
466        stop_entries.sort_by(|a, b| {
467            a.position
468                .partial_cmp(&b.position)
469                .unwrap_or(std::cmp::Ordering::Equal)
470        });
471        let stops: Vec<StopId> = stop_entries.iter().map(|s| s.id).collect();
472        let spawn = &config.passenger_spawning;
473        Self::new(
474            stops,
475            TrafficSchedule::constant(TrafficPattern::Uniform),
476            spawn.mean_interval_ticks,
477            spawn.weight_range,
478        )
479    }
480
481    /// Replace the traffic schedule.
482    #[must_use]
483    pub fn with_schedule(mut self, schedule: TrafficSchedule) -> Self {
484        self.schedule = schedule;
485        self
486    }
487
488    /// Replace the mean arrival interval and resample the next arrival.
489    ///
490    /// The first scheduled arrival is drawn in [`Self::new`] using whatever
491    /// mean the constructor received. Without resampling here, a chain like
492    /// `PoissonSource::new(stops, schedule, 1, range).with_mean_interval(1200)`
493    /// silently keeps the tick-0-ish arrival drawn at lambda = 1 — users
494    /// get their first rider ~1 tick in despite asking for one every 1200.
495    ///
496    /// The method draws `next_arrival_tick` afresh from the updated mean,
497    /// anchored to the source's current `next_arrival_tick` so that mid-
498    /// simulation calls do not rewind the anchor and trigger a catch-up
499    /// burst on the next [`generate`](TrafficSource::generate). See
500    /// [`with_rng`](Self::with_rng) for the analogous rationale.
501    #[must_use]
502    pub fn with_mean_interval(mut self, ticks: u32) -> Self {
503        self.mean_interval = ticks;
504        self.next_arrival_tick =
505            sample_next_arrival(self.next_arrival_tick, self.mean_interval, &mut self.rng);
506        // Treat an explicit interval change as committing to the current
507        // schedule; a later `with_rng` will not rewind the anchor.
508        self.rng_committed = true;
509        self
510    }
511
512    /// Tick of the next scheduled arrival.
513    ///
514    /// Exposed so callers (and tests) can confirm when the next spawn is
515    /// due without advancing the simulation.
516    #[must_use]
517    pub const fn next_arrival_tick(&self) -> u64 {
518        self.next_arrival_tick
519    }
520
521    /// Replace the internal RNG with a caller-supplied one.
522    ///
523    /// Pair with a seeded [`rand::rngs::StdRng`] (via
524    /// `StdRng::seed_from_u64(...)`) to make `PoissonSource` output
525    /// reproducible across runs — closing the gap called out in
526    /// [Snapshots and Determinism](https://andymai.github.io/elevator-core/snapshots-determinism.html).
527    ///
528    /// The next scheduled arrival is resampled from the new RNG, anchored
529    /// to the source's current `next_arrival_tick`. That means:
530    ///
531    /// - **At construction time** (the usual pattern, and what the doc
532    ///   example shows) the anchor is still the tick-0-ish draw from
533    ///   [`Self::new`]; resampling produces a fresh interval from there.
534    /// - **Mid-simulation** — if `with_rng` is called after the source has
535    ///   been stepped — the resample starts from the already-advanced
536    ///   anchor, so the next arrival is drawn forward from "now" rather
537    ///   than from tick 0. A naïve `sample_next_arrival(0, ...)` would
538    ///   rewind the anchor and cause the next `generate(tick)` call to
539    ///   catch-up-emit every backlogged arrival in a single burst.
540    ///
541    /// ```
542    /// use elevator_core::traffic::{PoissonSource, TrafficPattern, TrafficSchedule};
543    /// use elevator_core::stop::StopId;
544    /// use rand::SeedableRng;
545    ///
546    /// let seeded = rand::rngs::StdRng::seed_from_u64(42);
547    /// let source = PoissonSource::new(
548    ///     vec![StopId(0), StopId(1)],
549    ///     TrafficSchedule::constant(TrafficPattern::Uniform),
550    ///     120,
551    ///     (60.0, 90.0),
552    /// )
553    /// .with_rng(seeded);
554    /// # let _ = source;
555    /// ```
556    #[must_use]
557    pub fn with_rng(mut self, rng: rand::rngs::StdRng) -> Self {
558        self.rng = rng;
559        // If the schedule has not yet been committed (no `generate` calls,
560        // no `with_mean_interval`), reset the anchor to 0 so the new RNG
561        // drives sampling deterministically from the start. Otherwise the
562        // OS-seeded `next_arrival_tick` from `new()` would still anchor
563        // the schedule, defeating the determinism contract (#268).
564        let anchor = if self.rng_committed {
565            self.next_arrival_tick
566        } else {
567            0
568        };
569        self.next_arrival_tick = sample_next_arrival(anchor, self.mean_interval, &mut self.rng);
570        self.rng_committed = true;
571        self
572    }
573
574    /// Replace the weight range.
575    ///
576    /// If `range.0 > range.1`, the values are swapped.
577    #[must_use]
578    pub const fn with_weight_range(mut self, range: (f64, f64)) -> Self {
579        if range.0 > range.1 {
580            self.weight_range = (range.1, range.0);
581        } else {
582            self.weight_range = range;
583        }
584        self
585    }
586}
587
588impl TrafficSource for PoissonSource {
589    fn generate(&mut self, tick: u64) -> Vec<SpawnRequest> {
590        let mut requests = Vec::new();
591        // First call locks in the current schedule — subsequent `with_rng`
592        // must not rewind the anchor.
593        self.rng_committed = true;
594
595        while tick >= self.next_arrival_tick {
596            // Use the scheduled arrival tick (not the current tick) so catch-up
597            // arrivals sample from the pattern that was active when they were due.
598            let arrival_tick = self.next_arrival_tick;
599            if let Some((origin, destination)) =
600                self.schedule
601                    .sample_stop_ids(arrival_tick, &self.stops, &mut self.rng)
602            {
603                let weight = self
604                    .rng
605                    .random_range(self.weight_range.0..=self.weight_range.1);
606                requests.push(SpawnRequest {
607                    origin,
608                    destination,
609                    weight,
610                });
611            }
612            self.next_arrival_tick =
613                sample_next_arrival(self.next_arrival_tick, self.mean_interval, &mut self.rng);
614        }
615
616        requests
617    }
618}
619
620impl std::fmt::Debug for PoissonSource {
621    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
622        f.debug_struct("PoissonSource")
623            .field("stops", &self.stops)
624            .field("schedule", &self.schedule)
625            .field("mean_interval", &self.mean_interval)
626            .field("weight_range", &self.weight_range)
627            .field("next_arrival_tick", &self.next_arrival_tick)
628            .finish_non_exhaustive()
629    }
630}
631
632/// Sample the next arrival tick using exponential inter-arrival time.
633///
634/// The uniform sample is clamped to `[0.0001, 1.0)` to avoid `ln(0) = -inf`.
635/// This caps the maximum inter-arrival time at ~9.2× the mean interval,
636/// truncating the exponential tail to prevent rare extreme gaps.
637fn sample_next_arrival(current: u64, mean_interval: u32, rng: &mut impl RngExt) -> u64 {
638    if mean_interval == 0 {
639        return current + 1;
640    }
641    let u: f64 = rng.random_range(0.0001..1.0);
642    let interval = -(f64::from(mean_interval)) * u.ln();
643    current + (interval as u64).max(1)
644}