Skip to main content

crcbl_core/
schedule.rs

1//! Fixed-rate sampling at exact sub-tick times, staggered per agent.
2//!
3//! A [`FixedRateSchedule`] answers one question for one agent: *during the
4//! stretch of simulated time I was just handed, at which instants was I due to
5//! sample?* An AI that perceives at a fixed rate, a sensor that polls, a
6//! beacon that pings — each owns a schedule, feeds it the tick's elapsed time,
7//! and gets back every sample that fell inside it, with the exact simulation
8//! time it was scheduled for and how far into the update that was.
9//!
10//! ```
11//! use crcbl_core::schedule::{FixedRateSchedule, FixedRateScheduleConfig};
12//!
13//! let config = FixedRateScheduleConfig { interval_seconds: 0.1 };
14//! // Agents are staggered so they do not all sample on the same tick.
15//! let mut schedule = FixedRateSchedule::new(config, 0.025)?;
16//!
17//! let mut samples = Vec::new();
18//! schedule.advance(0.2, |sample| samples.push(sample))?;
19//! let times: Vec<f64> = samples
20//!     .iter()
21//!     .map(|sample| sample.scheduled_simulation_time_seconds)
22//!     .collect();
23//! assert_eq!(times, [0.025, 0.125]);
24//! # Ok::<(), Box<dyn std::error::Error>>(())
25//! ```
26//!
27//! Ported from EW, where it was `PerceptionSchedule`. Nothing in it knows about
28//! perception, so the engine names drop the word; every method, field, error
29//! variant and signature is otherwise EW's own.
30//!
31//! # Why this is not the frame clock
32//!
33//! [`FrameClock`](crate::time::FrameClock) is the simulation's own tick: one
34//! clock per loop, integer nanoseconds, driven by wall time, dropping ticks past
35//! its catch-up cap. This is the rung below it. It runs *inside* a tick, one per
36//! agent, at a rate that has nothing to do with the tick rate — a 10 Hz sense
37//! inside a 60 Hz simulation — and it reports samples at times *between* tick
38//! boundaries, so an agent can interpolate what it saw to the instant it looked.
39//! It never drops a sample: an update long enough to span several samples emits
40//! all of them, and time the agent genuinely was not running for goes through
41//! [`skip_elapsed`](FixedRateSchedule::skip_elapsed) instead.
42//!
43//! # Determinism
44//!
45//! Sample times are a pure function of the configuration, never of how the
46//! elapsed time was chopped up. The schedule counts samples with an integer
47//! index `k` and the `k`th sample is scheduled at exactly
48//!
49//! ```text
50//! phase_offset_seconds + (k as f64) * interval_seconds
51//! ```
52//!
53//! — one multiplication and one addition, each rounded once, which IEEE-754
54//! pins down on every target. The time is *not* accumulated by adding the
55//! interval to the previous sample time: that sum picks up a rounding error per
56//! sample, so the millionth sample of a long run would drift from where the
57//! formula puts it, and a schedule resumed by [`skip_elapsed`]
58//! (which has to jump straight to a later sample) would land somewhere a
59//! schedule that advanced continuously never reaches. EW's original did
60//! accumulate; the port changed only this, and every one of EW's tests still
61//! holds.
62//!
63//! The index is exact while it fits an `f64`'s mantissa, which
64//! [`MAX_SAMPLE_INDEX`] bounds; [`skip_elapsed`] refuses a jump past it, and
65//! [`advance`](FixedRateSchedule::advance) only ever steps it by one per
66//! emitted sample, so no run reaches it by advancing.
67//!
68//! The *elapsed* time an update covers is still accumulated — it is whatever
69//! the caller passes — so an update's end can land a rounding error short of a
70//! sample that is, in intent, exactly on it (ten updates of `0.1` sum to just
71//! below `1.0`). A sample within [`BOUNDARY_TOLERANCE_SCALE`] of the update's
72//! end, relative to the larger of the two magnitudes (and to one second, below
73//! it), is therefore due in *this* update rather than the next. Without it the
74//! sample on the boundary would fire one update late, on some partitions of
75//! the same time and not others.
76//!
77//! Within one update samples are emitted in increasing scheduled time, and each
78//! one's `offset_within_update_seconds` is measured from the update's start.
79//!
80//! [`skip_elapsed`]: FixedRateSchedule::skip_elapsed
81
82use core::fmt;
83
84/// The sample interval a default configuration uses: ten samples a second.
85pub const DEFAULT_INTERVAL_SECONDS: f64 = 0.1;
86
87/// How close, relative to the times involved, a sample has to be to an
88/// update's end to count as due in that update — see the [module
89/// docs](self#determinism) for why there is a tolerance at all.
90///
91/// Far above an `f64`'s own rounding error, so a sum of update lengths that
92/// meant to land on a sample always reaches it, and far below any interval a
93/// schedule is useful at. An interval under this scale of the current time
94/// cannot be told apart from the boundary it straddles, which is why
95/// [`FixedRateSchedule::skip_elapsed`] refuses one.
96pub const BOUNDARY_TOLERANCE_SCALE: f64 = 1e-12;
97
98/// The largest sample index the schedule will hold: `2^f64::MANTISSA_DIGITS`,
99/// the last integer from which every smaller one converts to an `f64` exactly.
100///
101/// Past it, two indices map to one `f64` and the time formula in the [module
102/// docs](self#determinism) stops being exact, so
103/// [`FixedRateSchedule::skip_elapsed`] refuses to jump there.
104pub const MAX_SAMPLE_INDEX: u64 = 1 << f64::MANTISSA_DIGITS;
105
106/// Configurable spacing between samples.
107#[derive(Clone, Copy, Debug, PartialEq)]
108pub struct FixedRateScheduleConfig {
109    /// Seconds of simulation time between one sample and the next. Finite and
110    /// positive; [`is_valid`](Self::is_valid) says whether it is.
111    pub interval_seconds: f64,
112}
113
114impl Default for FixedRateScheduleConfig {
115    /// [`DEFAULT_INTERVAL_SECONDS`].
116    fn default() -> Self {
117        Self {
118            interval_seconds: DEFAULT_INTERVAL_SECONDS,
119        }
120    }
121}
122
123impl FixedRateScheduleConfig {
124    /// Whether the interval is finite and positive — what
125    /// [`FixedRateSchedule::new`] requires.
126    #[must_use]
127    pub fn is_valid(self) -> bool {
128        self.interval_seconds.is_finite() && self.interval_seconds > 0.0
129    }
130}
131
132/// Construction rejected invalid sample timing or phase offset.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum FixedRateScheduleConfigError {
135    /// The interval is not finite and positive.
136    InvalidInterval,
137    /// The phase offset is not finite, or falls outside `[0, interval)`.
138    InvalidPhaseOffset,
139}
140
141impl fmt::Display for FixedRateScheduleConfigError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        f.write_str(match self {
144            Self::InvalidInterval => "sample interval must be finite and positive",
145            Self::InvalidPhaseOffset => {
146                "phase offset must be finite, at least zero and less than the interval"
147            }
148        })
149    }
150}
151
152impl std::error::Error for FixedRateScheduleConfigError {}
153
154/// An elapsed-time update was rejected without changing the schedule.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub enum FixedRateScheduleAdvanceError {
157    /// The elapsed time is negative or not finite, the time it would reach is
158    /// not finite, or — for [`FixedRateSchedule::skip_elapsed`] — the next
159    /// sample after it cannot be represented apart from the skip's end.
160    InvalidElapsed,
161}
162
163impl fmt::Display for FixedRateScheduleAdvanceError {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.write_str(match self {
166            Self::InvalidElapsed => {
167                "elapsed time must be finite, not negative, and reach a representable next sample"
168            }
169        })
170    }
171}
172
173impl std::error::Error for FixedRateScheduleAdvanceError {}
174
175/// One scheduled sample within an [`advance`](FixedRateSchedule::advance) call.
176#[derive(Clone, Copy, Debug, PartialEq)]
177pub struct FixedRateSample {
178    /// The simulation time the sample was scheduled for, on the schedule's own
179    /// clock (zero at construction).
180    pub scheduled_simulation_time_seconds: f64,
181    /// How far into this update the sample falls, from the update's start —
182    /// what an agent interpolates its view of the world by.
183    pub offset_within_update_seconds: f64,
184    /// Time since the previous sample, or since construction or the end of the
185    /// last [`skip_elapsed`](FixedRateSchedule::skip_elapsed) for the first
186    /// sample after either. What an agent decays its senses by.
187    pub elapsed_since_previous_sample_seconds: f64,
188}
189
190/// Per-agent fixed-rate sample schedule.
191///
192/// See the [module docs](self) for what it is for and what it guarantees.
193#[derive(Clone, Copy, Debug, PartialEq)]
194pub struct FixedRateSchedule {
195    config: FixedRateScheduleConfig,
196    phase_offset_seconds: f64,
197    simulation_time_seconds: f64,
198    last_sample_time_seconds: f64,
199    /// `k` in the time formula of the [module docs](self#determinism): the
200    /// index of the next sample not yet emitted or skipped.
201    next_sample_index: u64,
202}
203
204impl Default for FixedRateSchedule {
205    /// [`FixedRateScheduleConfig::default`] with no phase offset.
206    fn default() -> Self {
207        Self::new(FixedRateScheduleConfig::default(), 0.0)
208            .expect("default fixed-rate schedule is valid")
209    }
210}
211
212impl FixedRateSchedule {
213    /// A schedule at simulation time zero, sampling every
214    /// `config.interval_seconds` starting at `phase_offset_seconds`.
215    ///
216    /// Giving each agent a different phase in `[0, interval)` spreads their
217    /// samples across the interval instead of stacking them on one tick. A
218    /// phase of zero first samples one whole interval in, not at time zero, so
219    /// a freshly built agent does not sample before anything has happened.
220    ///
221    /// # Errors
222    ///
223    /// [`InvalidInterval`](FixedRateScheduleConfigError::InvalidInterval) if
224    /// the interval is not [valid](FixedRateScheduleConfig::is_valid), and
225    /// [`InvalidPhaseOffset`](FixedRateScheduleConfigError::InvalidPhaseOffset)
226    /// if the phase is not finite or falls outside `[0, interval)`.
227    pub fn new(
228        config: FixedRateScheduleConfig,
229        phase_offset_seconds: f64,
230    ) -> Result<Self, FixedRateScheduleConfigError> {
231        if !config.is_valid() {
232            return Err(FixedRateScheduleConfigError::InvalidInterval);
233        }
234        if !phase_offset_seconds.is_finite()
235            || phase_offset_seconds < 0.0
236            || phase_offset_seconds >= config.interval_seconds
237        {
238            return Err(FixedRateScheduleConfigError::InvalidPhaseOffset);
239        }
240        // Sample zero sits at the phase itself, which for a zero phase is time
241        // zero — the sample the doc comment above promises not to take.
242        let next_sample_index = u64::from(phase_offset_seconds == 0.0);
243        Ok(Self {
244            config,
245            phase_offset_seconds,
246            simulation_time_seconds: 0.0,
247            last_sample_time_seconds: 0.0,
248            next_sample_index,
249        })
250    }
251
252    /// The phase this schedule was built with.
253    #[must_use]
254    pub fn phase_offset_seconds(&self) -> f64 {
255        self.phase_offset_seconds
256    }
257
258    /// Simulation time this schedule has been advanced or skipped to.
259    #[must_use]
260    pub fn simulation_time_seconds(&self) -> f64 {
261        self.simulation_time_seconds
262    }
263
264    /// Advances the local clock without emitting samples for unavailable time.
265    ///
266    /// The next sample remains on the configured phase — exactly the one a
267    /// schedule [advanced](Self::advance) through the same time would reach
268    /// next — while its elapsed baseline starts at the skipped endpoint, so
269    /// sensory state cannot replay historical samples when an agent resumes.
270    ///
271    /// # Errors
272    ///
273    /// [`InvalidElapsed`](FixedRateScheduleAdvanceError::InvalidElapsed),
274    /// leaving the schedule untouched, if `elapsed_seconds` is negative or not
275    /// finite, if the time it reaches is not finite, or if the next sample
276    /// after it is past [`MAX_SAMPLE_INDEX`] or within
277    /// [`BOUNDARY_TOLERANCE_SCALE`] of the skip's end — an interval too small
278    /// for the time reached to tell one sample from the next.
279    pub fn skip_elapsed(
280        &mut self,
281        elapsed_seconds: f64,
282    ) -> Result<(), FixedRateScheduleAdvanceError> {
283        if !elapsed_seconds.is_finite() || elapsed_seconds < 0.0 {
284            return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
285        }
286        if elapsed_seconds == 0.0 {
287            return Ok(());
288        }
289        let skipped_end_seconds = self.simulation_time_seconds + elapsed_seconds;
290        if !skipped_end_seconds.is_finite() {
291            return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
292        }
293
294        let mut next_sample_index = self.next_sample_index;
295        if due_at_or_before(
296            self.sample_time_seconds(next_sample_index),
297            skipped_end_seconds,
298        ) {
299            // The first index strictly after the end, in exact arithmetic. The
300            // quotient is rounded, so the estimate can sit one either side of
301            // the index advancing would reach; the two corrections below put it
302            // back, and anything they cannot fix is an interval too small to
303            // schedule at this time and is refused below.
304            let estimate = ((skipped_end_seconds - self.phase_offset_seconds)
305                / self.config.interval_seconds)
306                .floor()
307                + 1.0;
308            // A power of two, so the conversion is exact.
309            #[allow(clippy::cast_precision_loss)]
310            let max_index = MAX_SAMPLE_INDEX as f64;
311            // An interval tiny against the time reached overflows the quotient.
312            if !estimate.is_finite() || estimate > max_index {
313                return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
314            }
315            // Integral and at most the bound, so the conversion is exact; a
316            // negative estimate (an end a rounding error before the phase)
317            // saturates to zero, and `max` then keeps the index moving forward
318            // past the sample that was due.
319            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
320            let estimate = estimate as u64;
321            next_sample_index = estimate.max(next_sample_index + 1);
322            if next_sample_index - 1 > self.next_sample_index
323                && !due_at_or_before(
324                    self.sample_time_seconds(next_sample_index - 1),
325                    skipped_end_seconds,
326                )
327            {
328                next_sample_index -= 1;
329            }
330            if due_at_or_before(
331                self.sample_time_seconds(next_sample_index),
332                skipped_end_seconds,
333            ) {
334                next_sample_index += 1;
335            }
336        }
337        let next_sample_time_seconds = self.sample_time_seconds(next_sample_index);
338        if next_sample_index > MAX_SAMPLE_INDEX
339            || !next_sample_time_seconds.is_finite()
340            || due_at_or_before(next_sample_time_seconds, skipped_end_seconds)
341        {
342            return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
343        }
344
345        self.simulation_time_seconds = skipped_end_seconds;
346        self.last_sample_time_seconds = skipped_end_seconds;
347        self.next_sample_index = next_sample_index;
348        Ok(())
349    }
350
351    /// Invokes `emit` once for every sample due in this elapsed-time update,
352    /// in increasing scheduled time.
353    ///
354    /// A sample is due when it is at or before the update's end, or within
355    /// [`BOUNDARY_TOLERANCE_SCALE`] of it; see the [module
356    /// docs](self#determinism). An update spanning several intervals emits
357    /// every sample in it — none are dropped or merged.
358    ///
359    /// # Errors
360    ///
361    /// [`InvalidElapsed`](FixedRateScheduleAdvanceError::InvalidElapsed),
362    /// leaving the schedule untouched and emitting nothing, if
363    /// `elapsed_seconds` is negative or not finite, or if the time it reaches
364    /// is not finite.
365    pub fn advance(
366        &mut self,
367        elapsed_seconds: f64,
368        mut emit: impl FnMut(FixedRateSample),
369    ) -> Result<(), FixedRateScheduleAdvanceError> {
370        if !elapsed_seconds.is_finite() || elapsed_seconds < 0.0 {
371            return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
372        }
373        let update_start_seconds = self.simulation_time_seconds;
374        let update_end_seconds = update_start_seconds + elapsed_seconds;
375        if !update_end_seconds.is_finite() {
376            return Err(FixedRateScheduleAdvanceError::InvalidElapsed);
377        }
378
379        loop {
380            let scheduled_simulation_time_seconds = self.next_sample_time_seconds();
381            if !due_at_or_before(scheduled_simulation_time_seconds, update_end_seconds) {
382                break;
383            }
384            emit(FixedRateSample {
385                scheduled_simulation_time_seconds,
386                offset_within_update_seconds: scheduled_simulation_time_seconds
387                    - update_start_seconds,
388                elapsed_since_previous_sample_seconds: scheduled_simulation_time_seconds
389                    - self.last_sample_time_seconds,
390            });
391            self.last_sample_time_seconds = scheduled_simulation_time_seconds;
392            // One step per emitted sample from at most `MAX_SAMPLE_INDEX`,
393            // which `new` and `skip_elapsed` both hold to, so this cannot
394            // overflow in any run that terminates.
395            self.next_sample_index += 1;
396        }
397        self.simulation_time_seconds = update_end_seconds;
398        Ok(())
399    }
400
401    /// When the next sample not yet emitted or skipped is scheduled, on the
402    /// schedule's own clock — the `scheduled_simulation_time_seconds` the next
403    /// [`advance`](Self::advance) that reaches it will emit first.
404    ///
405    /// A read: nothing is consumed, so a caller deciding how far to step before
406    /// an agent next samples asks this rather than advancing a copy of the
407    /// schedule through every sample of the interval. It is always after
408    /// [`simulation_time_seconds`](Self::simulation_time_seconds) by more than
409    /// the boundary tolerance — `advance` stops only once the next sample is
410    /// that far past its end, and [`skip_elapsed`](Self::skip_elapsed) refuses
411    /// a skip that would leave one due — so subtracting the two is how far
412    /// ahead the next sample falls.
413    #[must_use]
414    pub fn next_sample_time_seconds(&self) -> f64 {
415        self.sample_time_seconds(self.next_sample_index)
416    }
417
418    /// The time formula of the [module docs](self#determinism).
419    fn sample_time_seconds(&self, index: u64) -> f64 {
420        // Exact for every index up to `MAX_SAMPLE_INDEX`, the only ones the
421        // schedule holds.
422        #[allow(clippy::cast_precision_loss)]
423        let index = index as f64;
424        self.phase_offset_seconds + index * self.config.interval_seconds
425    }
426}
427
428/// Whether a sample scheduled at `scheduled_seconds` is due in an update
429/// ending at `update_end_seconds`: at or before it, or within
430/// [`BOUNDARY_TOLERANCE_SCALE`] of it relative to the larger magnitude, and
431/// never less than that scale of one second.
432fn due_at_or_before(scheduled_seconds: f64, update_end_seconds: f64) -> bool {
433    scheduled_seconds <= update_end_seconds
434        || scheduled_seconds - update_end_seconds
435            <= BOUNDARY_TOLERANCE_SCALE
436                * scheduled_seconds
437                    .abs()
438                    .max(update_end_seconds.abs())
439                    .max(1.0)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    const EPSILON: f64 = 1e-12;
447
448    fn event_times(events: &[FixedRateSample]) -> Vec<f64> {
449        events
450            .iter()
451            .map(|event| event.scheduled_simulation_time_seconds)
452            .collect()
453    }
454
455    fn assert_times_match(actual: &[f64], expected: &[f64]) {
456        assert_eq!(actual.len(), expected.len());
457        for (actual, expected) in actual.iter().zip(expected) {
458            assert!((actual - expected).abs() < EPSILON);
459        }
460    }
461
462    /// Every sample `schedule` emits across `steps`, in order.
463    fn collect(schedule: &mut FixedRateSchedule, steps: &[f64]) -> Vec<FixedRateSample> {
464        let mut events = Vec::new();
465        for &step in steps {
466            schedule.advance(step, |event| events.push(event)).unwrap();
467        }
468        events
469    }
470
471    // ----- EW's tests, renamed and otherwise unchanged -----
472
473    #[test]
474    fn default_schedule_emits_at_ten_hertz_boundaries() {
475        let mut schedule = FixedRateSchedule::default();
476        let mut events = Vec::new();
477
478        schedule.advance(0.099, |event| events.push(event)).unwrap();
479        assert!(events.is_empty());
480
481        schedule.advance(0.001, |event| events.push(event)).unwrap();
482        assert_eq!(events.len(), 1);
483        assert!((events[0].scheduled_simulation_time_seconds - 0.1).abs() < EPSILON);
484        assert!((events[0].offset_within_update_seconds - 0.001).abs() < EPSILON);
485        assert!((events[0].elapsed_since_previous_sample_seconds - 0.1).abs() < EPSILON);
486
487        events.clear();
488        schedule.advance(0.2, |event| events.push(event)).unwrap();
489        assert_times_match(&event_times(&events), &[0.2, 0.3]);
490    }
491
492    #[test]
493    fn coarse_and_partitioned_updates_emit_the_same_schedule() {
494        let mut coarse = FixedRateSchedule::default();
495        let mut partitioned = FixedRateSchedule::default();
496        let mut coarse_events = Vec::new();
497        let mut partitioned_events = Vec::new();
498
499        coarse
500            .advance(0.35, |event| coarse_events.push(event))
501            .unwrap();
502        for _ in 0..7 {
503            partitioned
504                .advance(0.05, |event| partitioned_events.push(event))
505                .unwrap();
506        }
507
508        assert_times_match(
509            &event_times(&coarse_events),
510            &event_times(&partitioned_events),
511        );
512        assert!(
513            (coarse.simulation_time_seconds() - partitioned.simulation_time_seconds()).abs()
514                < EPSILON
515        );
516    }
517
518    #[test]
519    fn phase_offsets_stagger_the_first_and_later_samples() {
520        let mut schedule = FixedRateSchedule::new(FixedRateScheduleConfig::default(), 0.025)
521            .expect("phase within the interval");
522        let mut events = Vec::new();
523
524        schedule.advance(0.024, |event| events.push(event)).unwrap();
525        assert!(events.is_empty());
526        schedule.advance(0.001, |event| events.push(event)).unwrap();
527        assert_times_match(&event_times(&events), &[0.025]);
528        assert!((events[0].elapsed_since_previous_sample_seconds - 0.025).abs() < EPSILON);
529
530        events.clear();
531        schedule.advance(0.1, |event| events.push(event)).unwrap();
532        assert_times_match(&event_times(&events), &[0.125]);
533    }
534
535    #[test]
536    fn invalid_configuration_and_elapsed_time_preserve_schedule_state() {
537        let invalid_interval = FixedRateScheduleConfig {
538            interval_seconds: 0.0,
539        };
540        assert_eq!(
541            FixedRateSchedule::new(invalid_interval, 0.0),
542            Err(FixedRateScheduleConfigError::InvalidInterval)
543        );
544        assert_eq!(
545            FixedRateSchedule::new(FixedRateScheduleConfig::default(), 0.1),
546            Err(FixedRateScheduleConfigError::InvalidPhaseOffset)
547        );
548
549        let mut schedule = FixedRateSchedule::default();
550        schedule.advance(0.04, |_| {}).unwrap();
551        let before = schedule;
552        assert_eq!(
553            schedule.advance(f64::NAN, |_| {}),
554            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
555        );
556        assert_eq!(schedule, before);
557        assert_eq!(
558            schedule.advance(-0.1, |_| {}),
559            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
560        );
561        assert_eq!(schedule, before);
562        assert_eq!(
563            schedule.skip_elapsed(f64::INFINITY),
564            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
565        );
566        assert_eq!(schedule, before);
567    }
568
569    #[test]
570    fn skipping_elapsed_time_keeps_phase_without_replaying_samples() {
571        let mut schedule = FixedRateSchedule::new(
572            FixedRateScheduleConfig {
573                interval_seconds: 0.25,
574            },
575            0.1,
576        )
577        .unwrap();
578        schedule.advance(0.05, |_| {}).unwrap();
579        schedule.skip_elapsed(0.96).unwrap();
580
581        assert!((schedule.simulation_time_seconds - 1.01).abs() < EPSILON);
582        assert!((schedule.last_sample_time_seconds - 1.01).abs() < EPSILON);
583        assert!((schedule.next_sample_time_seconds() - 1.1).abs() < EPSILON);
584
585        let mut events = Vec::new();
586        schedule.advance(0.09, |event| events.push(event)).unwrap();
587        assert_times_match(&event_times(&events), &[1.1]);
588        assert!((events[0].elapsed_since_previous_sample_seconds - 0.09).abs() < EPSILON);
589    }
590
591    #[test]
592    fn skipping_unrepresentable_or_tolerance_due_time_preserves_schedule() {
593        let mut tiny_interval = FixedRateSchedule::new(
594            FixedRateScheduleConfig {
595                interval_seconds: f64::MIN_POSITIVE,
596            },
597            0.0,
598        )
599        .unwrap();
600        let tiny_before = tiny_interval;
601        assert_eq!(
602            tiny_interval.skip_elapsed(1.0),
603            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
604        );
605        assert_eq!(tiny_interval, tiny_before);
606
607        let mut tolerance_due = FixedRateSchedule::new(
608            FixedRateScheduleConfig {
609                interval_seconds: 1e-13,
610            },
611            0.0,
612        )
613        .unwrap();
614        let tolerance_before = tolerance_due;
615        assert_eq!(
616            tolerance_due.skip_elapsed(1.0),
617            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
618        );
619        assert_eq!(tolerance_due, tolerance_before);
620    }
621
622    #[test]
623    fn skipping_a_large_elapsed_interval_uses_a_future_sample_boundary() {
624        let mut schedule = FixedRateSchedule::default();
625        schedule.skip_elapsed(1_000_000_000.0).unwrap();
626        assert!(!due_at_or_before(
627            schedule.next_sample_time_seconds(),
628            schedule.simulation_time_seconds
629        ));
630        assert_eq!(
631            schedule.last_sample_time_seconds,
632            schedule.simulation_time_seconds
633        );
634    }
635
636    // ----- The engine's own -----
637
638    #[test]
639    fn every_sample_lands_exactly_on_the_index_formula_over_a_long_run() {
640        // Ten minutes of a 60 Hz simulation: thousands of samples, far more
641        // than enough for an accumulated time to drift off the formula.
642        let phase = 0.03;
643        let config = FixedRateScheduleConfig::default();
644        let mut schedule = FixedRateSchedule::new(config, phase).unwrap();
645        let events = collect(&mut schedule, &[1.0 / 60.0; 60 * 600]);
646
647        assert!(events.len() > 5000, "the run emitted {}", events.len());
648        for (k, event) in events.iter().enumerate() {
649            let expected = phase + k as f64 * config.interval_seconds;
650            assert_eq!(
651                event.scheduled_simulation_time_seconds.to_bits(),
652                expected.to_bits(),
653                "sample {k}"
654            );
655        }
656    }
657
658    #[test]
659    fn a_sample_on_the_update_boundary_fires_in_that_update() {
660        // Summed updates of the interval land a rounding error short of some
661        // samples that are, in intent, exactly on their ends (ten of them sum
662        // to just under 1.0); every sample is still due in its own update.
663        let mut schedule = FixedRateSchedule::default();
664        let mut per_update = Vec::new();
665        for _ in 0..10 {
666            let mut count = 0;
667            schedule.advance(0.1, |_| count += 1).unwrap();
668            per_update.push(count);
669        }
670        assert!(schedule.simulation_time_seconds() < 1.0);
671        assert_eq!(per_update, [1; 10]);
672    }
673
674    #[test]
675    fn split_and_whole_updates_emit_bit_identical_samples() {
676        let config = FixedRateScheduleConfig {
677            interval_seconds: 0.07,
678        };
679        let mut whole = FixedRateSchedule::new(config, 0.011).unwrap();
680        let mut split = FixedRateSchedule::new(config, 0.011).unwrap();
681
682        let whole_events = collect(&mut whole, &[12.5]);
683        // Uneven steps, some shorter and some longer than the interval.
684        let steps: Vec<f64> = [0.013, 0.2, 0.0, 0.041, 0.5, 0.0005]
685            .iter()
686            .copied()
687            .cycle()
688            .take(1000)
689            .collect();
690        let total: f64 = steps.iter().sum();
691        let split_events = collect(&mut split, &steps);
692
693        // The totals differ by rounding, so compare the samples both covered.
694        let covered = total.min(12.5) - config.interval_seconds;
695        let whole_times: Vec<u64> = whole_events
696            .iter()
697            .filter(|event| event.scheduled_simulation_time_seconds < covered)
698            .map(|event| event.scheduled_simulation_time_seconds.to_bits())
699            .collect();
700        let split_times: Vec<u64> = split_events
701            .iter()
702            .filter(|event| event.scheduled_simulation_time_seconds < covered)
703            .map(|event| event.scheduled_simulation_time_seconds.to_bits())
704            .collect();
705        assert!(whole_times.len() > 100);
706        assert_eq!(whole_times, split_times);
707    }
708
709    #[test]
710    fn two_schedules_fed_the_same_inputs_agree_bit_for_bit() {
711        let config = FixedRateScheduleConfig {
712            interval_seconds: 1.0 / 30.0,
713        };
714        let steps = [0.016, 0.017, 0.2, 0.001, 0.033];
715        let mut a = FixedRateSchedule::new(config, 0.005).unwrap();
716        let mut b = FixedRateSchedule::new(config, 0.005).unwrap();
717        let a_events = collect(&mut a, &steps);
718        let b_events = collect(&mut b, &steps);
719
720        assert!(!a_events.is_empty());
721        let bits = |event: &FixedRateSample| {
722            [
723                event.scheduled_simulation_time_seconds.to_bits(),
724                event.offset_within_update_seconds.to_bits(),
725                event.elapsed_since_previous_sample_seconds.to_bits(),
726            ]
727        };
728        assert_eq!(
729            a_events.iter().map(bits).collect::<Vec<_>>(),
730            b_events.iter().map(bits).collect::<Vec<_>>()
731        );
732        assert_eq!(a, b);
733    }
734
735    #[test]
736    fn samples_are_ordered_and_offsets_lie_within_their_update() {
737        let mut schedule = FixedRateSchedule::new(
738            FixedRateScheduleConfig {
739                interval_seconds: 0.02,
740            },
741            0.007,
742        )
743        .unwrap();
744        let mut previous = f64::NEG_INFINITY;
745        for step in [0.1, 0.013, 0.5, 0.019] {
746            schedule
747                .advance(step, |event| {
748                    assert!(event.scheduled_simulation_time_seconds > previous);
749                    previous = event.scheduled_simulation_time_seconds;
750                    assert!(event.offset_within_update_seconds > 0.0);
751                    assert!(event.offset_within_update_seconds <= step + EPSILON);
752                })
753                .unwrap();
754        }
755    }
756
757    #[test]
758    fn skipping_emits_nothing_and_resumes_where_advancing_would() {
759        let config = FixedRateScheduleConfig {
760            interval_seconds: 0.1,
761        };
762        for phase in [0.0, 0.013, 0.05, 0.099] {
763            for skip in [0.05, 0.3, 1.0, 7.77, 1234.5678] {
764                let mut skipped = FixedRateSchedule::new(config, phase).unwrap();
765                let mut advanced = skipped;
766                skipped.advance(0.02, |_| {}).unwrap();
767                advanced.advance(0.02, |_| {}).unwrap();
768
769                skipped.skip_elapsed(skip).unwrap();
770                advanced.advance(skip, |_| {}).unwrap();
771
772                let after = 0.25;
773                let skipped_events = collect(&mut skipped, &[after]);
774                let advanced_events = collect(&mut advanced, &[after]);
775                assert_eq!(
776                    event_times(&skipped_events)
777                        .iter()
778                        .map(|time| time.to_bits())
779                        .collect::<Vec<_>>(),
780                    event_times(&advanced_events)
781                        .iter()
782                        .map(|time| time.to_bits())
783                        .collect::<Vec<_>>(),
784                    "phase {phase}, skip {skip}"
785                );
786                // The first sample after a skip measures from the skip's end,
787                // not from a sample that was never taken.
788                let first = skipped_events[0];
789                assert!(
790                    (first.elapsed_since_previous_sample_seconds
791                        - first.offset_within_update_seconds)
792                        .abs()
793                        < EPSILON
794                );
795            }
796        }
797    }
798
799    #[test]
800    fn skipping_emits_nothing_on_the_next_update_for_the_skipped_time() {
801        let mut schedule = FixedRateSchedule::default();
802        schedule.skip_elapsed(5.0).unwrap();
803        assert!(collect(&mut schedule, &[0.0]).is_empty());
804        assert!(collect(&mut schedule, &[0.09]).is_empty());
805    }
806
807    #[test]
808    fn a_rejected_skip_past_the_index_bound_leaves_the_schedule_alone() {
809        let mut schedule = FixedRateSchedule::new(
810            FixedRateScheduleConfig {
811                interval_seconds: 1e-3,
812            },
813            0.0,
814        )
815        .unwrap();
816        schedule.advance(0.0105, |_| {}).unwrap();
817        let before = schedule;
818        // Enough time for far more samples than the index can hold exactly.
819        assert_eq!(
820            schedule.skip_elapsed(1e14),
821            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
822        );
823        assert_eq!(schedule, before);
824        assert_eq!(
825            schedule.skip_elapsed(-1.0),
826            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
827        );
828        assert_eq!(schedule, before);
829        assert_eq!(
830            schedule.advance(f64::INFINITY, |_| panic!("emitted on a rejected update")),
831            Err(FixedRateScheduleAdvanceError::InvalidElapsed)
832        );
833        assert_eq!(schedule, before);
834    }
835
836    #[test]
837    fn phase_stagger_spreads_agents_across_the_interval() {
838        let config = FixedRateScheduleConfig::default();
839        let agents = 8_u32;
840        let mut schedules: Vec<FixedRateSchedule> = (0..agents)
841            .map(|i| {
842                let phase = f64::from(i) * config.interval_seconds / f64::from(agents);
843                FixedRateSchedule::new(config, phase).unwrap()
844            })
845            .collect();
846
847        // Warm past the zero-phase agent's first interval, then look at one
848        // full interval: every agent samples once, each at a different time.
849        for schedule in &mut schedules {
850            schedule.advance(config.interval_seconds, |_| {}).unwrap();
851        }
852        let mut times = Vec::new();
853        for schedule in &mut schedules {
854            let events = collect(schedule, &[config.interval_seconds]);
855            assert_eq!(events.len(), 1, "phase {}", schedule.phase_offset_seconds());
856            times.push(events[0].scheduled_simulation_time_seconds);
857        }
858        times.sort_by(f64::total_cmp);
859        let spacing = config.interval_seconds / f64::from(agents);
860        for pair in times.windows(2) {
861            assert!((pair[1] - pair[0] - spacing).abs() < EPSILON);
862        }
863    }
864
865    #[test]
866    fn errors_display_what_was_wrong() {
867        assert!(
868            FixedRateScheduleConfigError::InvalidInterval
869                .to_string()
870                .contains("interval")
871        );
872        assert!(
873            FixedRateScheduleConfigError::InvalidPhaseOffset
874                .to_string()
875                .contains("phase")
876        );
877        assert!(
878            FixedRateScheduleAdvanceError::InvalidElapsed
879                .to_string()
880                .contains("elapsed")
881        );
882    }
883
884    /// The first sample the next `advance` emits, or `None` if a step that
885    /// long emits none.
886    fn first_emitted(schedule: &FixedRateSchedule, step: f64) -> Option<f64> {
887        let mut copy = *schedule;
888        let mut first = None;
889        copy.advance(step, |event| {
890            first.get_or_insert(event.scheduled_simulation_time_seconds);
891        })
892        .unwrap();
893        first
894    }
895
896    /// **The next sample's time is a read of the sample the schedule emits
897    /// next**, bit for bit, whatever the phase, the steps and the skips before
898    /// it — and asking does not consume it. EW finds an agent's next sample by
899    /// advancing a copy through the whole interval; this is that answer without
900    /// the walk.
901    #[test]
902    fn the_next_sample_time_is_the_sample_emitted_next_and_reading_it_takes_nothing() {
903        let config = FixedRateScheduleConfig::default();
904        // A step long enough to reach any next sample.
905        let reach = config.interval_seconds * 2.0;
906        for phase in [0.0, 0.025, 0.099] {
907            let mut schedule = FixedRateSchedule::new(config, phase).expect("phase within");
908            let first = schedule.next_sample_time_seconds();
909            if phase == 0.0 {
910                // A zero phase first samples one interval in, not at zero.
911                assert_eq!(first.to_bits(), config.interval_seconds.to_bits());
912            } else {
913                assert_eq!(first.to_bits(), phase.to_bits());
914            }
915
916            for (round, step) in [0.013, 0.05, 0.0, 0.31, 0.1, 0.007].into_iter().enumerate() {
917                let next = schedule.next_sample_time_seconds();
918                assert_eq!(
919                    next.to_bits(),
920                    schedule.next_sample_time_seconds().to_bits(),
921                    "phase {phase}, round {round}: reading it twice moved it"
922                );
923                assert!(next > schedule.simulation_time_seconds());
924                assert_eq!(
925                    Some(next.to_bits()),
926                    first_emitted(&schedule, reach).map(f64::to_bits),
927                    "phase {phase}, round {round}: not the sample emitted next"
928                );
929                if round % 2 == 0 {
930                    schedule.advance(step, |_| {}).unwrap();
931                } else {
932                    schedule.skip_elapsed(step).unwrap();
933                }
934            }
935        }
936    }
937}