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