Skip to main content

vj_sim/sched/
wheel.rs

1//! SCHED::WHEEL — the hierarchical time wheel (scheduler.md §2; ledger
2//! SC-029, SC-030, SC-032).
3//!
4//! A 4-level, 256-slot-per-level hashed hierarchical timing wheel (the
5//! classic "hashed and hierarchical timing wheels" construction) plus a
6//! `BTreeMap` far-future overflow for delays beyond the four levels' combined
7//! range (`256^4` ticks). Each level's slot spans `256^level` ticks; when the
8//! wheel's own `current` position crosses a level's boundary, that level's
9//! current-position bucket is cascaded — its entries are re-sorted into
10//! whichever level/slot their *actual* absolute tick now resolves to (always
11//! the same level or a strictly lower one, since the remaining delta to fire
12//! only shrinks as `current` advances) — which is what lets a delay far
13//! beyond level 0's 256-slot span still fire on the exact correct tick
14//! without every intervening tick doing real work (SC-032's "advances now to
15//! the next non-empty tick" driver relies on this).
16//!
17//! `TimerEntry` (SC-029) deliberately carries no `at`/tick field of its own —
18//! the wheel tracks each entry's absolute tick internally (alongside it in
19//! each slot) precisely so `arm_delay`'s zero-delay case can skip the wheel
20//! entirely (return `Region::Inactive` for the caller to push directly onto
21//! [`crate::sched::RegionQueues`]) rather than needing a wheel-internal
22//! special case.
23//!
24//! FIFO order within one slot (`VecDeque`, SC-030) is preserved end to end —
25//! including through a cascade, which drains a bucket in its own FIFO order
26//! and re-inserts each entry via the same `push_back` path a fresh `arm`
27//! would use — but that FIFO order is explicitly *not* the determinism
28//! guarantee itself (SC-030's negative half): it is only ever an input to
29//! [`crate::sched::sort_ready_set`]'s canonical ordering, which a
30//! `DeterminismController` (Task 3) actually dispatches by.
31
32use std::collections::{BTreeMap, VecDeque};
33
34use vj_abi::Region;
35
36use crate::sched::{PendingActivation, SimTick};
37use crate::state::StateCellId;
38
39const LEVELS: usize = 4;
40const SLOTS: usize = 256;
41
42// SC-026's own op-count instrumentation (test-only, zero cost in
43// production): counts one "operation" per `insert_relative_at` call (an
44// arm, a cascade's re-insert, or an overflow migration's re-insert) and one
45// per element a `pop_due`/`cascade_boundary` drain actually visits -- the
46// literal set the plan enumerates ("one insert, one cascade-check, one
47// pop, one re-insert-on-non-match") -- so a test can assert total work is
48// bounded by a small constant times the number of *entries*, never by the
49// span of ticks advanced over.
50#[cfg(test)]
51thread_local! {
52    static OP_COUNT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
53}
54
55#[cfg(test)]
56fn count_op() {
57    OP_COUNT.with(|c| c.set(c.get() + 1));
58}
59
60#[cfg(test)]
61fn reset_op_counter() {
62    OP_COUNT.with(|c| c.set(0));
63}
64
65#[cfg(test)]
66fn op_counter() -> u64 {
67    OP_COUNT.with(|c| c.get())
68}
69
70/// What a fired [`TimerEntry`] actually does (B-sched Task 6, ledger
71/// SC-069). Originally `TimerEntry` carried only `{activation, region}` --
72/// "resume this activation" -- which had no way to express "trigger this
73/// named event" at all: an `->>#5 my_event;` (a delayed nonblocking event
74/// trigger, LRM 10.6.3) needs a wheel entry whose fired action wakes every
75/// `@(my_event)` waiter, never resumes any particular process's own
76/// continuation directly (there may be no continuation to resume at all --
77/// the triggering process, if `->>`'s enclosing statement has already
78/// returned control, might not even still exist as a live activation by the
79/// time this fires).
80#[derive(Clone, Copy, Debug)]
81pub enum TimerAction {
82    /// The original, only-ever-constructed-until-now shape: resume
83    /// `activation` in `region` (`region` is almost always `Region::Active`;
84    /// it exists so a `Suspend::Delay(0)` can be represented uniformly even
85    /// though [`TimeWheel::arm_delay`]'s zero-delay case never actually
86    /// constructs one -- it redirects to `Region::Inactive` before the wheel
87    /// is touched at all).
88    Resume(PendingActivation, Region),
89    /// SC-069: fire the named-event trigger at `StateCellId` -- fans out
90    /// through the exact same `ValueChangeSink::notify_change` path an
91    /// ordinary committed write already uses (`Engine::advance_time_to`,
92    /// called with `EdgeKind::None`, the sentinel `SensitivityTable::matches`
93    /// already treats as "any change"/a trigger pulse), never conflated with
94    /// resuming whoever originally armed the trigger.
95    TriggerEvent(StateCellId),
96}
97
98/// One armed wheel entry (SC-029, SC-069). All fields are private outside
99/// this crate's `sched` module -- nothing depends on this being a
100/// single-field wrapper vs. some other internal shape, only on
101/// `arm`/`arm_delay`/`arm_trigger`/`next_tick`/`advance_to`'s documented
102/// behavior.
103#[derive(Clone, Copy, Debug)]
104pub struct TimerEntry {
105    pub action: TimerAction,
106}
107
108/// The hierarchical time wheel (SC-029, SC-030, SC-032). See the module doc
109/// comment for the level/cascade design. All fields are private — nothing
110/// outside this module depends on the internal level/slot layout, only on
111/// `arm`/`arm_delay`/`next_tick`/`advance_to`'s documented behavior.
112pub struct TimeWheel {
113    /// `levels[l][slot]` holds `(at, TimerEntry)` pairs currently assigned to
114    /// level `l`'s slot `slot`, where `slot == (at >> (8 * l)) & 0xFF` at the
115    /// time the entry was last placed (by `arm` or by a cascade).
116    levels: [Vec<VecDeque<(SimTick, TimerEntry)>>; LEVELS],
117    /// Entries whose delta from `current` exceeds every level's range
118    /// (`>= 256^LEVELS` ticks out). Promoted into the levels as `current`
119    /// advances close enough (see `cascade`).
120    overflow: BTreeMap<SimTick, Vec<TimerEntry>>,
121    /// The wheel's own notion of "now" — the last tick `advance_to` was
122    /// called with (0 for a fresh wheel that has never advanced).
123    current: SimTick,
124    /// How many entries each level holds, so `next_tick` skips empty levels.
125    counts: [usize; LEVELS],
126    /// The earliest `at` across levels 1..LEVELS, when known (`None` inside
127    /// the cell = must be recomputed): kept by `insert_relative_at`,
128    /// invalidated when a cascade moves entries out of those levels, so a
129    /// long-pending far timer does not make every `next_tick` rescan them.
130    upper_min: std::cell::Cell<Option<Option<SimTick>>>,
131}
132
133impl Default for TimeWheel {
134    fn default() -> Self {
135        Self {
136            levels: std::array::from_fn(|_| (0..SLOTS).map(|_| VecDeque::new()).collect()),
137            overflow: BTreeMap::new(),
138            current: 0,
139            counts: [0; LEVELS],
140            upper_min: std::cell::Cell::new(Some(None)),
141        }
142    }
143}
144
145impl TimeWheel {
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Which level a `delta`-ticks-from-`current` entry belongs in, or `None`
151    /// for the far-future overflow map. Level `l`'s range is `[256^l,
152    /// 256^(l+1))` ticks from `current`, so an entry's level only ever
153    /// shrinks (never grows) as `current` advances and `delta` shrinks —
154    /// this is what makes cascading a one-way, terminating redistribution.
155    fn level_for(delta: SimTick) -> Option<usize> {
156        const L0: u64 = SLOTS as u64;
157        const L1: u64 = L0 * SLOTS as u64;
158        const L2: u64 = L1 * SLOTS as u64;
159        const L3: u64 = L2 * SLOTS as u64;
160        if delta < L0 {
161            Some(0)
162        } else if delta < L1 {
163            Some(1)
164        } else if delta < L2 {
165            Some(2)
166        } else if delta < L3 {
167            Some(3)
168        } else {
169            None
170        }
171    }
172
173    /// Places `(at, entry)` into the correct level/slot (or overflow) *as
174    /// of* `as_of` — the one insertion path shared by `arm` (fresh entries,
175    /// `as_of == self.current`) and `cascade_boundary`/
176    /// `migrate_overflow_due_by` (redistributed entries, `as_of` the exact
177    /// tick the redistribution is happening at, which may be earlier than
178    /// `self.current` if the caller hasn't advanced the field yet). Always
179    /// recomputing fresh from `(at, as_of)` — rather than trusting an
180    /// entry's *previous* level/slot — is what makes deferring a
181    /// redistribution to a later, coarser-grained check (SC-026/031's
182    /// bounded-cost requirement, see `advance_to`) still land every entry
183    /// exactly where an eager per-tick walk would have: `level_for`'s
184    /// output only ever shrinks as `as_of` grows, so computing it once at
185    /// the final `as_of` is equivalent to recomputing it at every
186    /// intermediate tick and always agrees with the last such recomputation.
187    fn insert_relative_at(&mut self, at: SimTick, entry: TimerEntry, as_of: SimTick) {
188        #[cfg(test)]
189        count_op();
190        let delta = at.saturating_sub(as_of);
191        match Self::level_for(delta) {
192            Some(l) => {
193                let slot = ((at >> (8 * l)) & 0xFF) as usize;
194                self.levels[l][slot].push_back((at, entry));
195                self.counts[l] += 1;
196                if l > 0
197                    && let Some(known) = self.upper_min.get()
198                {
199                    self.upper_min
200                        .set(Some(Some(known.map_or(at, |m| m.min(at)))));
201                }
202            }
203            None => {
204                self.overflow.entry(at).or_default().push(entry);
205            }
206        }
207    }
208
209    /// `insert_relative_at` as of the wheel's own current position — the
210    /// path `arm` uses for a fresh entry.
211    fn insert_relative(&mut self, at: SimTick, entry: TimerEntry) {
212        self.insert_relative_at(at, entry, self.current);
213    }
214
215    /// Inserts `entry`, due at absolute tick `at`, into the correct
216    /// level/slot or the far-future overflow map.
217    pub fn arm(&mut self, at: SimTick, entry: TimerEntry) {
218        self.insert_relative(at, entry);
219    }
220
221    /// The earliest tick with any pending entry across every level and the
222    /// overflow map, or `None` if the wheel is entirely empty. Does not
223    /// mutate the wheel (SC-032's driver calls this before deciding whether
224    /// to advance at all).
225    ///
226    /// Must scan every entry in every slot, not just `front()`: at levels
227    /// 1-3 a single slot legitimately holds entries with different absolute
228    /// `at` values (any two ticks sharing the byte at bit-position `8*l`
229    /// land in the same slot regardless of their other bytes), inserted in
230    /// whatever order the caller armed them — so the FIFO-front entry is not
231    /// necessarily the chronologically earliest one in that slot.
232    /// Every tick with an entry, ascending and without duplicates.
233    pub fn pending_ticks(&self) -> std::collections::BTreeSet<SimTick> {
234        let mut out: std::collections::BTreeSet<SimTick> = self.overflow.keys().copied().collect();
235        for level in &self.levels {
236            for slot in level {
237                out.extend(slot.iter().map(|(at, _)| *at));
238            }
239        }
240        out
241    }
242
243    pub fn next_tick(&self) -> Option<SimTick> {
244        let mut min: Option<SimTick> = None;
245        // Level 0 holds only ticks in `[current, current + 256)` (an entry
246        // goes there when it is less than 256 ticks out, and time never
247        // advances past a pending tick), one tick per slot: the first
248        // non-empty slot from `current`'s, circularly, is the level's earliest.
249        if self.counts[0] > 0 {
250            let start = (self.current & 0xFF) as usize;
251            for k in 0..SLOTS {
252                let slot = &self.levels[0][(start + k) % SLOTS];
253                if let Some(at) = slot.iter().map(|(at, _)| *at).min() {
254                    debug_assert!(at >= self.current, "a level-0 entry is in the past");
255                    min = Some(at);
256                    break;
257                }
258            }
259        }
260        let upper = match self.upper_min.get() {
261            Some(known) => known,
262            None => {
263                let mut m: Option<SimTick> = None;
264                for l in 1..LEVELS {
265                    if self.counts[l] == 0 {
266                        continue;
267                    }
268                    for slot in &self.levels[l] {
269                        for (at, _) in slot.iter() {
270                            m = Some(m.map_or(*at, |x| x.min(*at)));
271                        }
272                    }
273                }
274                self.upper_min.set(Some(m));
275                m
276            }
277        };
278        if let Some(at) = upper {
279            min = Some(min.map_or(at, |m| m.min(at)));
280        }
281        if let Some((&at, _)) = self.overflow.iter().next() {
282            min = Some(min.map_or(at, |m| m.min(at)));
283        }
284        min
285    }
286
287    /// Cascades every level whose bucket for exactly `at_tick` must now be
288    /// redistributed because `at_tick` is a multiple of that level's
289    /// boundary (`256^level`) — the classic hierarchical-wheel rule. Does
290    /// *not* touch the far-future overflow map (see `migrate_overflow_due_by`,
291    /// called once per `advance_to`, not once per boundary — unlike the
292    /// levels, an overflow entry's placement doesn't depend on which
293    /// specific boundary tick it gets migrated at, only on the final `now`,
294    /// so batching it is free; the levels genuinely do need each boundary
295    /// visited, since an entry sitting in level `l`'s bucket for slot `s`
296    /// only becomes reachable — and only gets a chance at a *further*
297    /// cascade into a lower level — at the exact tick that bucket is due to
298    /// roll over).
299    ///
300    /// A caller only ever needs to call this at ticks where `at_tick %
301    /// SLOTS == 0` (see `advance_to`'s doc comment for why every other tick
302    /// is a guaranteed no-op here: levels 2 and 3's boundaries are
303    /// themselves multiples of level 1's `SLOTS`-tick boundary, so a tick
304    /// that isn't can never satisfy any level's `is_multiple_of` check).
305    fn cascade_boundary(&mut self, at_tick: SimTick) {
306        for l in 1..LEVELS {
307            let boundary = (SLOTS as u64).pow(l as u32);
308            if at_tick.is_multiple_of(boundary) {
309                let slot = ((at_tick >> (8 * l)) & 0xFF) as usize;
310                if self.levels[l][slot].is_empty() {
311                    continue;
312                }
313                let bucket: Vec<(SimTick, TimerEntry)> = self.levels[l][slot].drain(..).collect();
314                self.counts[l] -= bucket.len();
315                // Entries leave the upper levels: their minimum is stale.
316                self.upper_min.set(None);
317                for (at, entry) in bucket {
318                    self.insert_relative_at(at, entry, at_tick);
319                }
320            }
321        }
322    }
323
324    /// The smallest tick `m >= from` (a multiple of `SLOTS`) at which some
325    /// level `l >= 1` has a non-empty bucket due to roll over (`m` a
326    /// multiple of `256^l`, bucket `(m >> 8l) & 0xFF`), or `None` if no
327    /// upper level holds an entry. At most `SLOTS` probes per occupied level.
328    fn next_occupied_boundary(&self, from: SimTick) -> Option<SimTick> {
329        let mut best: Option<SimTick> = None;
330        for l in 1..LEVELS {
331            if self.counts[l] == 0 {
332                continue;
333            }
334            let step = (SLOTS as u64).pow(l as u32);
335            let first = from.div_ceil(step).saturating_mul(step);
336            for k in 0..SLOTS as u64 {
337                let m = first.saturating_add(k.saturating_mul(step));
338                if best.is_some_and(|b| m >= b) {
339                    break;
340                }
341                if !self.levels[l][((m >> (8 * l)) & 0xFF) as usize].is_empty() {
342                    best = Some(m);
343                    break;
344                }
345            }
346        }
347        best
348    }
349
350    /// Promotes every far-future overflow entry now within the top level's
351    /// range of `as_of` (i.e. `key < as_of + 256^LEVELS`) into the levels,
352    /// placed exactly where `insert_relative_at` puts an entry that far out
353    /// as of `as_of`. Called once per `advance_to` call with the wheel's
354    /// *final* new `current` — SC-025/027/031's migration requirement is
355    /// about an entry eventually becoming reachable at the correct tick,
356    /// not about which specific intermediate tick performs the move, and
357    /// `insert_relative_at` always recomputes fresh from `(at, as_of)`
358    /// rather than trusting prior state, so migrating once at the final
359    /// `as_of` instead of re-checking on every intervening tick is
360    /// observably identical (delta only ever shrinks as `as_of` grows, so
361    /// no entry can be placed too early, and any entry due at or before
362    /// this call's target tick has `key <= as_of < threshold`, so it is
363    /// always captured by this single check).
364    fn migrate_overflow_due_by(&mut self, as_of: SimTick) {
365        if self.overflow.is_empty() {
366            return;
367        }
368        let threshold = as_of.saturating_add((SLOTS as u64).pow(LEVELS as u32));
369        let due_keys: Vec<SimTick> = self.overflow.range(..threshold).map(|(k, _)| *k).collect();
370        for key in due_keys {
371            if let Some(entries) = self.overflow.remove(&key) {
372                for entry in entries {
373                    self.insert_relative_at(key, entry, as_of);
374                }
375            }
376        }
377    }
378
379    /// Pops every entry stored for exactly `tick` out of level 0's slot for
380    /// `tick` (plus a defensive overflow check, for a directly-`arm`ed
381    /// far-future entry whose tick happens to be reached without ever
382    /// cascading through a level-3 boundary — see `cascade`'s promotion
383    /// step, which should already have moved it in by then, but this keeps
384    /// `advance_to` correct even if `cascade`'s promotion window were ever
385    /// narrowed). Preserves FIFO order (SC-030) — `VecDeque::drain` yields
386    /// in push order, and `due` is built by iterating that drain in order.
387    fn pop_due(&mut self, tick: SimTick) -> Vec<TimerEntry> {
388        let slot = (tick & 0xFF) as usize;
389        let bucket = &mut self.levels[0][slot];
390        let mut due = Vec::with_capacity(bucket.len());
391        // Level 0 holds one tick per slot (see `next_tick`), so normally
392        // everything here is due; anything else goes back, in order. Draining
393        // in place keeps the slot's buffer for the next time it fills.
394        let mut later: Vec<(SimTick, TimerEntry)> = Vec::new();
395        for (at, entry) in bucket.drain(..) {
396            #[cfg(test)]
397            count_op();
398            if at == tick {
399                due.push(entry);
400            } else {
401                later.push((at, entry));
402            }
403        }
404        bucket.extend(later);
405        self.counts[0] -= due.len();
406        if let Some(entries) = self.overflow.remove(&tick) {
407            due.extend(entries);
408        }
409        due
410    }
411
412    /// Advances the wheel's own `current` position up to `tick` (a no-op
413    /// walk if `tick <= current` already), cascading any level-1+ buckets
414    /// that boundary-cross along the way, then pops and returns every entry
415    /// due at exactly `tick`. Preserves same-slot FIFO order (SC-030).
416    ///
417    /// SC-026/028/031's bounded-cost requirement: this deliberately does
418    /// *not* walk every integer tick between the old and new `current` (an
419    /// earlier revision did, at `O(tick - current)` cost — 27s wall-clock
420    /// for a single `256^4`-tick jump on this machine, measured before this
421    /// fix). Every level-1..3 cascade can only ever fire at a tick that is a
422    /// multiple of `SLOTS` (level 1's own boundary — levels 2 and 3's
423    /// boundaries, `SLOTS^2` and `SLOTS^3`, are themselves multiples of
424    /// `SLOTS`), so every other integer tick is a *guaranteed* no-op for
425    /// every level; this walks only the multiples of `SLOTS` in
426    /// `(old current, tick]` (`cascade_boundary`), an `O((tick - current) /
427    /// SLOTS)` bound — and does the far-future overflow migration exactly
428    /// once per call (`migrate_overflow_due_by`, see its own doc comment
429    /// for why once-per-call is equivalent to once-per-tick) rather than
430    /// once per tick, which is what made the far-future overflow check
431    /// itself `O(tick - current)` even though cascading alone wasn't.
432    pub fn advance_to(&mut self, tick: SimTick) -> Vec<TimerEntry> {
433        if tick > self.current {
434            let slots = SLOTS as u64;
435            let mut boundary = (self.current / slots + 1) * slots;
436            // Visit only the boundaries whose bucket is non-empty
437            // (`cascade_boundary` is a no-op on every other one), so a jump
438            // of 10^12 ticks (`#1s` at a 1 ps precision, LRM 3.14.3) costs
439            // O(occupied buckets), not O(tick / SLOTS).
440            while boundary <= tick {
441                match self.next_occupied_boundary(boundary) {
442                    Some(b) if b <= tick => {
443                        self.cascade_boundary(b);
444                        boundary = b + slots;
445                    }
446                    _ => break,
447                }
448            }
449            self.current = tick;
450            self.migrate_overflow_due_by(tick);
451        }
452        self.pop_due(tick)
453    }
454
455    /// The one entry point a `Suspend::Delay` normalizes through (SC-029 —
456    /// "without a special-cased wheel code path" is satisfied by the wheel
457    /// simply never being touched for a same-delta redirect, not by an `if`
458    /// inside `arm`). `delay_ticks == 0` returns `Region::Inactive` without
459    /// calling `arm` at all; any other delay arms at `now + delay_ticks`
460    /// with `region: Region::Active` and returns `Region::Active`.
461    pub fn arm_delay(&mut self, now: SimTick, delay_ticks: u64, act: PendingActivation) -> Region {
462        if delay_ticks == 0 {
463            return Region::Inactive;
464        }
465        self.arm(
466            now + delay_ticks,
467            TimerEntry {
468                action: TimerAction::Resume(act, Region::Active),
469            },
470        );
471        Region::Active
472    }
473
474    /// SC-069: arms a delayed named-event trigger (`->>#N my_event;`'s
475    /// scheduler-side shape) to fire at absolute tick `at`. Unlike
476    /// `arm_delay`, there is no `delay_ticks == 0` special case here -- an
477    /// undelayed `->` never suspends/touches the wheel at all (it fires the
478    /// trigger synchronously through the same `notify_change` fan-out,
479    /// scheduler.md §8), so every call to this method genuinely arms the
480    /// wheel.
481    pub fn arm_trigger(&mut self, at: SimTick, cell: StateCellId) {
482        self.arm(
483            at,
484            TimerEntry {
485                action: TimerAction::TriggerEvent(cell),
486            },
487        );
488    }
489}
490
491/// Test-only structural accessors (SC-024/025/027/028/031's own closure
492/// tests read internal layout directly — the doc comment on each call site
493/// says which invariant it pins). Additive: no production caller uses
494/// these, only `#[cfg(test)]` code, so they carry no run-time cost outside
495/// tests.
496#[cfg(test)]
497impl TimeWheel {
498    /// The number of slots at each of the 4 levels (pinned at `[256, 256,
499    /// 256, 256]` by SC-024 — fails if `LEVELS`/`SLOTS` ever changes without
500    /// updating that pinned test).
501    fn level_slot_counts(&self) -> [usize; LEVELS] {
502        std::array::from_fn(|l| self.levels[l].len())
503    }
504
505    /// Total entries currently sitting in the far-future overflow map
506    /// (summed across every distinct key, not the key count).
507    fn overflow_len(&self) -> usize {
508        self.overflow.values().map(|v| v.len()).sum()
509    }
510
511    /// Total entries currently sitting anywhere in level `l` (summed across
512    /// every slot at that level).
513    fn level_len(&self, l: usize) -> usize {
514        self.levels[l].iter().map(|slot| slot.len()).sum()
515    }
516
517    /// The number of entries in exactly `levels[level][slot]`.
518    fn slot_len(&self, level: usize, slot: usize) -> usize {
519        self.levels[level][slot].len()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::sched::{ResumeArg, SchedTime, sort_ready_set};
527
528    fn pending_activation(id: u64, registration_order: u64) -> PendingActivation {
529        PendingActivation {
530            id,
531            proc: id as u32,
532            arg: ResumeArg::Start,
533            reconv_iter: 0,
534            logical_order: (SchedTime { ticks: 0 }, Region::Active, registration_order),
535        }
536    }
537
538    fn timer_entry(id: u64) -> TimerEntry {
539        TimerEntry {
540            action: TimerAction::Resume(pending_activation(id, id), Region::Active),
541        }
542    }
543
544    fn timer_entry_with_registration(id: u64, registration_order: u64) -> TimerEntry {
545        TimerEntry {
546            action: TimerAction::Resume(pending_activation(id, registration_order), Region::Active),
547        }
548    }
549
550    /// Test-only unwrap: every existing (pre-SC-069) test only ever arms and
551    /// fires `Resume` entries via `timer_entry`/`timer_entry_with_registration`
552    /// above -- this panics loudly (rather than silently misreading a
553    /// `TriggerEvent` entry's bytes) if that ever stops being true for one of
554    /// them.
555    fn resume_activation(entry: &TimerEntry) -> PendingActivation {
556        match entry.action {
557            TimerAction::Resume(act, _) => act,
558            TimerAction::TriggerEvent(cell) => {
559                panic!("expected a Resume timer entry, got TriggerEvent({cell:?})")
560            }
561        }
562    }
563
564    // SC-030 (positive half): same-tick entries fire in fixed intra-bucket
565    // FIFO order.
566    #[test]
567    fn same_tick_entries_fire_in_fifo_insertion_order() {
568        let mut wheel = TimeWheel::new();
569        // Armed (inserted) in this order: id 3, then 1, then 2.
570        wheel.arm(10, timer_entry(3));
571        wheel.arm(10, timer_entry(1));
572        wheel.arm(10, timer_entry(2));
573
574        let fired = wheel.advance_to(10);
575        let ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
576        assert_eq!(
577            ids,
578            vec![3, 1, 2],
579            "same-tick entries must fire in FIFO arming order, never HashMap/id order"
580        );
581    }
582
583    // SC-030 (negative half): wheel FIFO order feeds the canonical sort, but
584    // is not itself the determinism guarantee.
585    #[test]
586    fn wheel_fifo_order_is_not_itself_the_determinism_guarantee() {
587        let mut wheel = TimeWheel::new();
588        // Arming order: id 1, then 2, then 3 — but each entry's
589        // registration_order (logical_order.2, sort_ready_set's tiebreak)
590        // deliberately disagrees with that arming order.
591        wheel.arm(5, timer_entry_with_registration(1, 30));
592        wheel.arm(5, timer_entry_with_registration(2, 10));
593        wheel.arm(5, timer_entry_with_registration(3, 20));
594
595        let fired = wheel.advance_to(5);
596        let fifo_ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
597        assert_eq!(
598            fifo_ids,
599            vec![1, 2, 3],
600            "the wheel itself must still report plain arming-order FIFO"
601        );
602
603        let mut acts: Vec<PendingActivation> = fired.iter().map(resume_activation).collect();
604        sort_ready_set(&mut acts);
605        let sorted_ids: Vec<u64> = acts.iter().map(|a| a.id).collect();
606
607        assert_ne!(
608            sorted_ids, fifo_ids,
609            "canonical sort must be able to reorder relative to wheel FIFO order — \
610             if this ever equals fifo_ids the test fixture's registration_order \
611             disagreement stopped being exercised"
612        );
613        assert_eq!(
614            sorted_ids,
615            vec![2, 3, 1],
616            "canonical order follows registration_order (logical_order.2), not wheel arming order"
617        );
618    }
619
620    // SC-029: Delay(0) never touches the wheel, redirects to Inactive.
621    #[test]
622    fn arm_delay_zero_never_touches_the_wheel_and_redirects_to_inactive() {
623        let mut wheel = TimeWheel::new();
624        let before = wheel.next_tick();
625
626        let region = wheel.arm_delay(100, 0, pending_activation(1, 0));
627
628        assert_eq!(region, Region::Inactive);
629        let after = wheel.next_tick();
630        assert_eq!(
631            before, after,
632            "arm_delay(.., 0, ..) must not change wheel state at all"
633        );
634        assert_eq!(
635            after, None,
636            "a wheel that has only ever seen a delay-0 arm_delay call must remain empty"
637        );
638    }
639
640    // Feeds SC-032: `run_until`'s "wheel empty -> terminate" check needs
641    // next_tick() to actually report emptiness rather than e.g. panicking or
642    // returning a sentinel tick.
643    #[test]
644    fn next_tick_returns_none_when_wheel_and_overflow_are_both_empty() {
645        let wheel = TimeWheel::new();
646        assert_eq!(wheel.next_tick(), None);
647    }
648
649    #[test]
650    fn advance_to_cascades_a_higher_level_bucket_into_level_zero() {
651        let mut wheel = TimeWheel::new();
652        // 300 ticks is beyond level 0's 256-slot span, so this must land in
653        // level 1 (or higher) and be cascaded down as `current` advances.
654        wheel.arm(300, timer_entry(42));
655
656        for tick in 1..300 {
657            let fired = wheel.advance_to(tick);
658            assert!(
659                fired.is_empty(),
660                "entry armed at tick 300 must not fire early, but fired at tick {tick}"
661            );
662        }
663
664        let fired = wheel.advance_to(300);
665        let ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
666        assert_eq!(
667            ids,
668            vec![42],
669            "entry armed at tick 300 must fire exactly at tick 300 after cascading \
670             from level 1 into level 0 — a dropped or double-fired cascade would fail this"
671        );
672
673        let fired_again = wheel.advance_to(301);
674        assert!(
675            fired_again.is_empty(),
676            "an entry must never fire a second time after its tick has passed"
677        );
678    }
679
680    // LRM 3.14.3: `#1s` at a 1 ps global precision is one 10^12-tick jump. The
681    // cascade walk visits only occupied boundaries, so this returns at once
682    // (it used to take 4*10^9 iterations) and still fires every entry on time.
683    #[test]
684    fn advance_to_a_huge_jump_cascades_only_occupied_boundaries_and_fires_on_time() {
685        let mut wheel = TimeWheel::new();
686        let far = 1_000_000_000_000u64;
687        wheel.arm(far, timer_entry(1));
688        wheel.arm(far + 300, timer_entry(2));
689        wheel.arm(far - 5, timer_entry(3));
690        wheel.arm(7, timer_entry(4));
691        let start = std::time::Instant::now();
692        assert_eq!(wheel.next_tick(), Some(7));
693        let ids = |w: &mut TimeWheel, t| -> Vec<u64> {
694            w.advance_to(t)
695                .iter()
696                .map(|e| resume_activation(e).id)
697                .collect()
698        };
699        assert_eq!(ids(&mut wheel, 7), vec![4]);
700        assert_eq!(wheel.next_tick(), Some(far - 5));
701        assert_eq!(ids(&mut wheel, far - 5), vec![3]);
702        assert_eq!(ids(&mut wheel, far), vec![1]);
703        assert_eq!(wheel.next_tick(), Some(far + 300));
704        assert_eq!(ids(&mut wheel, far + 300), vec![2]);
705        assert_eq!(wheel.next_tick(), None);
706        assert!(
707            start.elapsed().as_secs() < 5,
708            "the jump must not walk every boundary"
709        );
710    }
711
712    // Regression: next_tick() must report the true minimum `at` across every
713    // entry in a slot, not just the FIFO front. Two entries with different
714    // `at` can legitimately collide in the same level>=1 slot (any two ticks
715    // sharing the byte at bit-position `8*l` land in the same slot
716    // regardless of their other bytes); arming the chronologically LATER one
717    // first must not make next_tick() report it as the earliest.
718    #[test]
719    fn next_tick_finds_the_true_minimum_when_two_different_at_entries_collide_in_one_higher_level_slot()
720     {
721        let mut wheel = TimeWheel::new();
722        // 1000 >> 8 == 3 and 800 >> 8 == 3: both land in level 1, slot 3.
723        // Armed in descending `at` order (later-due first) so a naive
724        // front()-only scan would report 1000 instead of the true min 800.
725        wheel.arm(1000, timer_entry(100));
726        wheel.arm(800, timer_entry(200));
727
728        assert_eq!(
729            wheel.next_tick(),
730            Some(800),
731            "next_tick() must return the chronologically earliest `at` in the \
732             colliding slot (800), not the FIFO-front entry (1000)"
733        );
734
735        // Driving the wheel via the documented next_tick()/advance_to() loop
736        // must fire entries in non-decreasing `at` order end-to-end, and the
737        // wheel's `current` must never race past an unfired earlier entry.
738        let mut fired_order: Vec<(SimTick, u64)> = Vec::new();
739        while let Some(t) = wheel.next_tick() {
740            for entry in wheel.advance_to(t) {
741                fired_order.push((t, resume_activation(&entry).id));
742            }
743        }
744
745        assert_eq!(
746            fired_order,
747            vec![(800, 200), (1000, 100)],
748            "entries must be reported in non-decreasing `at` order: the tick-800 \
749             entry (id 200) before the tick-1000 entry (id 100)"
750        );
751    }
752
753    // Regression: arm_delay's nonzero-delay path (the branch every real
754    // `#delay` statement will use) must actually arm the wheel and fire at
755    // the correct absolute tick — not just return `Region::Active` without
756    // scheduling anything.
757    #[test]
758    fn arm_delay_nonzero_arms_the_wheel_and_fires_at_the_correct_tick() {
759        let mut wheel = TimeWheel::new();
760        let now: SimTick = 42;
761        let delay = 5;
762
763        let region = wheel.arm_delay(now, delay, pending_activation(7, 0));
764
765        assert_eq!(region, Region::Active);
766        assert_eq!(
767            wheel.next_tick(),
768            Some(now + delay),
769            "arm_delay(now, delay, ..) must arm the wheel to fire at now + delay"
770        );
771
772        let fired = wheel.advance_to(now + delay);
773        let ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
774        assert_eq!(
775            ids,
776            vec![7],
777            "the activation armed via arm_delay must actually fire at the \
778             correct tick — a forgotten self.arm() call would leave this empty"
779        );
780    }
781
782    // SC-024: struct layout is exactly 4 levels of 256 slots each.
783    #[test]
784    fn sc024_wheel_has_exactly_four_levels_of_256_slots_each() {
785        let wheel = TimeWheel::new();
786        assert_eq!(
787            wheel.level_slot_counts(),
788            [256, 256, 256, 256],
789            "the wheel must have exactly 4 hierarchical levels of 256 \
790             slots each — a change to LEVELS/SLOTS without updating this \
791             pinned test must fail here first"
792        );
793    }
794
795    // SC-025: a delay beyond every level's range (past 256^4 ticks, level
796    // 3's own top) lands in the far-future BTreeMap overflow, is visible
797    // from there via next_tick() (never silently dropped/truncated), and
798    // fires exactly once when advanced to directly from a fresh wheel —
799    // proving advance_to's cost is not an O(delay) walk (see advance_to's
800    // own doc comment: an earlier, naive per-tick-walk implementation took
801    // 27s wall-clock for exactly this jump; this call must complete in a
802    // small fraction of a second).
803    #[test]
804    fn sc025_a_delay_beyond_every_level_lands_in_overflow_and_fires_exactly_once() {
805        let mut wheel = TimeWheel::new();
806        let far: SimTick = 256u64.pow(4) + 1000;
807        wheel.arm(far, timer_entry(99));
808
809        assert_eq!(
810            wheel.overflow_len(),
811            1,
812            "must be sitting in the overflow map"
813        );
814        for l in 0..4 {
815            assert_eq!(
816                wheel.level_len(l),
817                0,
818                "must not be in any level {l} bucket before any advance"
819            );
820        }
821        assert_eq!(
822            wheel.next_tick(),
823            Some(far),
824            "an entry beyond every level's range must still be visible via \
825             next_tick() from the overflow map, never silently dropped or truncated"
826        );
827
828        let fired = wheel.advance_to(far);
829        let ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
830        assert_eq!(
831            ids,
832            vec![99],
833            "must fire exactly once, at the exact far-future tick, when \
834             advanced to directly from a fresh wheel with no intervening steps"
835        );
836
837        let fired_again = wheel.advance_to(far + 1);
838        assert!(
839            fired_again.is_empty(),
840            "must never fire a second time after its tick has passed"
841        );
842    }
843
844    // SC-027: an entry starting in the far-future overflow map migrates
845    // into the level structure as `current` (driven forward in coarse
846    // strides, not one tick at a time) approaches within the wheel's total
847    // span of it, and fires at the exact correct tick afterward. Also
848    // proves the migration is a real, observable state transition (not
849    // already-there-by-construction): the entry is confirmed to be in
850    // overflow and NOT in any level before the threshold, then confirmed to
851    // be out of overflow and IN a level after it.
852    #[test]
853    fn sc027_overflow_entry_migrates_into_the_wheel_as_now_approaches_it() {
854        let mut wheel = TimeWheel::new();
855        // 100_000 ticks past the wheel's total span (256^4) -- comfortably
856        // in overflow (SC-025's own boundary), with a migration threshold
857        // (`far - 256^4 == 100_000`) far enough out that reaching it takes
858        // many coarse strides, not a lucky single step.
859        let far: SimTick = 256u64.pow(4) + 100_000;
860        let migration_point: SimTick = far - 256u64.pow(4); // == 100_000
861        wheel.arm(far, timer_entry(7));
862
863        assert_eq!(wheel.overflow_len(), 1);
864        for l in 0..4 {
865            assert_eq!(wheel.level_len(l), 0);
866        }
867
868        let stride: SimTick = 256;
869        let mut current: SimTick = 0;
870        while current <= migration_point {
871            current += stride;
872            let fired = wheel.advance_to(current);
873            assert!(fired.is_empty(), "must not fire before its tick");
874            if current <= migration_point {
875                assert_eq!(
876                    wheel.overflow_len(),
877                    1,
878                    "must not migrate out of overflow before `current` is \
879                     within the wheel's total span of it (current={current}, \
880                     migration_point={migration_point})"
881                );
882                for l in 0..4 {
883                    assert_eq!(
884                        wheel.level_len(l),
885                        0,
886                        "must not appear in level {l} before the migration point"
887                    );
888                }
889            }
890        }
891
892        // `current` has now crossed the migration point: the entry must be
893        // physically out of overflow and sitting in exactly one level.
894        assert_eq!(
895            wheel.overflow_len(),
896            0,
897            "must have migrated out of overflow once `current` is within \
898             the wheel's total span of its tick"
899        );
900        let total_in_levels: usize = (0..4).map(|l| wheel.level_len(l)).sum();
901        assert_eq!(
902            total_in_levels, 1,
903            "must be sitting in exactly one level bucket after migration"
904        );
905        assert_eq!(
906            wheel.next_tick(),
907            Some(far),
908            "must remain discoverable at its exact original tick after migration"
909        );
910
911        let fired = wheel.advance_to(far);
912        let ids: Vec<u64> = fired.iter().map(|t| resume_activation(t).id).collect();
913        assert_eq!(
914            ids,
915            vec![7],
916            "must fire at the exact correct tick after migrating in from overflow"
917        );
918    }
919
920    // SC-026: bounded insert/advance cost, expressed as a deterministic
921    // operation-count assertion (never a wall-clock benchmark — this
922    // codebase's own memory notes flag wall-clock thresholds as a gate that
923    // cannot fail/pass reliably). 100_000 entries at 100_000 distinct
924    // near-term ticks, drained one tick at a time; total instrumented
925    // `insert_relative_at`/`pop_due`-visit count must stay linear in the
926    // number of *entries*, never quadratic and never proportional to some
927    // unrelated span of ticks. A `BinaryHeap`/re-sorted-`Vec` reimplementation
928    // (O(log n) or O(n) per operation, scaling with total resident entries)
929    // would blow past this bound at N=100_000.
930    #[test]
931    fn sc026_dense_near_term_insert_and_advance_cost_is_bounded_per_operation() {
932        reset_op_counter();
933        let mut wheel = TimeWheel::new();
934        const N: u64 = 100_000;
935        for t in 1..=N {
936            wheel.arm(t, timer_entry(t));
937        }
938
939        let mut total_fired = 0usize;
940        for t in 1..=N {
941            total_fired += wheel.advance_to(t).len();
942        }
943        assert_eq!(
944            total_fired, N as usize,
945            "every armed entry must eventually fire exactly once"
946        );
947
948        // K=4: one insert (arm), at most one cascade re-insert as the entry
949        // migrates down through the levels it started in, and one pop-scan
950        // visit when it fires -- an upper bound per entry, not a tight one.
951        const K: u64 = 4;
952        let ops = op_counter();
953        assert!(
954            ops <= N * K,
955            "instrumented op count {ops} exceeded the O(1)-amortized-per-entry \
956             bound of {N}*{K}={}; a wrong implementation whose per-operation \
957             cost scales with the total resident entry count would blow past \
958             this bound at N={N}",
959            N * K
960        );
961    }
962
963    // SC-028: level-0 bucket membership is exact `tick mod 256` placement
964    // (O(1) direct index), never a scan that could collide or leave a slot
965    // empty. 256 entries at ticks 0..256 (all within level 0's own range)
966    // must land one-per-slot, with slot index equal to the tick itself.
967    #[test]
968    fn sc028_level_zero_bucket_membership_is_exact_tick_mod_256_not_a_scan() {
969        let mut wheel = TimeWheel::new();
970        for t in 0..256u64 {
971            wheel.arm(t, timer_entry(t));
972        }
973        for slot in 0..256usize {
974            assert_eq!(
975                wheel.slot_len(0, slot),
976                1,
977                "level 0 slot {slot} must hold exactly the one entry armed \
978                 at that exact tick — direct index placement, not a scan \
979                 that could double up one slot and leave another empty"
980            );
981        }
982    }
983
984    // SC-031: a mixed near-future/far-future workload (a `#1`-style clock
985    // running right alongside a single far-future timeout) shares one wheel
986    // with no unbounded preallocation and no migration duplication. The
987    // outer level arrays are fixed-size Vecs by construction; this asserts
988    // that invariant holds under 10_000 iterations of sustained near-term
989    // load, not just by inspection, and that the far-future entry is never
990    // duplicated by a buggy migration path.
991    #[test]
992    fn sc031_mixed_near_and_far_future_delays_share_the_wheel_with_no_unbounded_preallocation() {
993        let mut wheel = TimeWheel::new();
994        let far: SimTick = 256u64.pow(4) + 100_000_000;
995        wheel.arm(far, timer_entry(1));
996        assert_eq!(
997            wheel.overflow_len(),
998            1,
999            "an entry beyond the wheel's total span must start in overflow"
1000        );
1001
1002        let mut now: SimTick = 0;
1003        for _ in 0..10_000u64 {
1004            now += 1;
1005            wheel.arm(now, timer_entry(2));
1006            let fired = wheel.advance_to(now);
1007            assert_eq!(
1008                fired.len(),
1009                1,
1010                "the near-term entry armed one tick ahead must fire exactly \
1011                 on schedule every iteration"
1012            );
1013
1014            assert_eq!(
1015                wheel.level_slot_counts(),
1016                [256, 256, 256, 256],
1017                "the outer level arrays must never grow or reallocate under \
1018                 sustained near-term load next to a far-future entry"
1019            );
1020            assert!(
1021                wheel.overflow_len() <= 1,
1022                "the far-future entry must never be duplicated by a buggy \
1023                 migration path"
1024            );
1025        }
1026    }
1027
1028    // SC-081/082 (B-sched Task 2, scheduler-level proof): two independent
1029    // re-arming chains -- chain A (id 0) re-arms itself every 5 ticks,
1030    // chain B (id 1) every 7 ticks -- driven for 70 ticks (2 full periods
1031    // of the 35-tick LCM beat) via the exact `next_tick()`/`advance_to()`
1032    // loop `Engine::run_until` itself uses. Chain A's fire set must be
1033    // exactly {5,10,...,70} and chain B's exactly {7,14,...,70}, with BOTH
1034    // firing at the beat coincidences (tick 35 and tick 70) -- a same-slot
1035    // collision bug that merges or drops one chain's entry at a shared tick
1036    // would show up here as a missing or duplicated fire at exactly 35/70.
1037    #[test]
1038    fn two_coprime_period_chains_interleave_with_no_missed_edges_at_their_beat_frequency() {
1039        const PERIOD_A: u64 = 5;
1040        const PERIOD_B: u64 = 7;
1041        const HORIZON: u64 = 70;
1042
1043        let mut wheel = TimeWheel::new();
1044        wheel.arm(PERIOD_A, timer_entry(0));
1045        wheel.arm(PERIOD_B, timer_entry(1));
1046
1047        let mut fired_a: Vec<u64> = Vec::new();
1048        let mut fired_b: Vec<u64> = Vec::new();
1049        let mut fired_pairs: Vec<(u64, u64)> = Vec::new();
1050
1051        while let Some(t) = wheel.next_tick() {
1052            if t > HORIZON {
1053                break;
1054            }
1055            for entry in wheel.advance_to(t) {
1056                fired_pairs.push((t, resume_activation(&entry).id));
1057                match resume_activation(&entry).id {
1058                    0 => {
1059                        fired_a.push(t);
1060                        if t + PERIOD_A <= HORIZON {
1061                            wheel.arm(t + PERIOD_A, timer_entry(0));
1062                        }
1063                    }
1064                    1 => {
1065                        fired_b.push(t);
1066                        if t + PERIOD_B <= HORIZON {
1067                            wheel.arm(t + PERIOD_B, timer_entry(1));
1068                        }
1069                    }
1070                    other => panic!("unexpected chain id {other}"),
1071                }
1072            }
1073        }
1074
1075        let expected_a: Vec<u64> = (1..=14).map(|k| k * PERIOD_A).collect();
1076        let expected_b: Vec<u64> = (1..=10).map(|k| k * PERIOD_B).collect();
1077        assert_eq!(
1078            fired_a, expected_a,
1079            "chain A (period {PERIOD_A}) must fire at exactly every multiple \
1080             of its own period up to the horizon, with no missed or \
1081             duplicated edge"
1082        );
1083        assert_eq!(
1084            fired_b, expected_b,
1085            "chain B (period {PERIOD_B}) must fire at exactly every multiple \
1086             of its own period up to the horizon, with no missed or \
1087             duplicated edge"
1088        );
1089        assert!(
1090            fired_pairs.contains(&(35, 0)) && fired_pairs.contains(&(35, 1)),
1091            "both chains must fire at their first beat coincidence (tick 35) \
1092             -- a same-tick cross-chain collision that merges or drops one \
1093             chain's entry would fail this: got {fired_pairs:?}"
1094        );
1095        assert!(
1096            fired_pairs.contains(&(70, 0)) && fired_pairs.contains(&(70, 1)),
1097            "both chains must fire at their second beat coincidence (tick \
1098             70) -- got {fired_pairs:?}"
1099        );
1100    }
1101
1102    // SC-082 (construction proof at the wheel level): two chains with
1103    // periods 2500 and 3500 ticks -- not integer multiples of each other
1104    // at a coarse (e.g. 1ns) tick unit, but exact integers at the actual
1105    // tick precision (representing 2.5ns/3.5ns at 1ps precision) -- driven
1106    // for 4 periods each. Every fired tick for each chain must be an exact
1107    // multiple of its own period with zero cumulative drift: a
1108    // rational-timescale-arithmetic bug (float accumulation, integer
1109    // division truncation) would show up as e.g. `9999` instead of
1110    // `10000`, which a flat-`u64`-tick-counter implementation (SC-023)
1111    // cannot exhibit by construction -- this test is the proof, not a
1112    // hand-wave.
1113    #[test]
1114    fn sc082_tick_precision_represents_two_non_integer_multiple_periods_exactly() {
1115        const PERIOD_A: u64 = 2500;
1116        const PERIOD_B: u64 = 3500;
1117        const REPS: usize = 4;
1118
1119        let mut wheel = TimeWheel::new();
1120        wheel.arm(PERIOD_A, timer_entry(0));
1121        wheel.arm(PERIOD_B, timer_entry(1));
1122
1123        let mut fired_a: Vec<u64> = Vec::new();
1124        let mut fired_b: Vec<u64> = Vec::new();
1125
1126        while fired_a.len() < REPS || fired_b.len() < REPS {
1127            let t = wheel
1128                .next_tick()
1129                .expect("wheel must not go empty before both chains complete their 4 periods");
1130            for entry in wheel.advance_to(t) {
1131                match resume_activation(&entry).id {
1132                    0 => {
1133                        fired_a.push(t);
1134                        if fired_a.len() < REPS {
1135                            wheel.arm(t + PERIOD_A, timer_entry(0));
1136                        }
1137                    }
1138                    1 => {
1139                        fired_b.push(t);
1140                        if fired_b.len() < REPS {
1141                            wheel.arm(t + PERIOD_B, timer_entry(1));
1142                        }
1143                    }
1144                    other => panic!("unexpected chain id {other}"),
1145                }
1146            }
1147        }
1148
1149        assert_eq!(
1150            fired_a,
1151            vec![2500, 5000, 7500, 10000],
1152            "chain A's fired ticks must be exact integer multiples of its \
1153             own 2500-tick period with zero cumulative drift"
1154        );
1155        assert_eq!(
1156            fired_b,
1157            vec![3500, 7000, 10500, 14000],
1158            "chain B's fired ticks must be exact integer multiples of its \
1159             own 3500-tick period with zero cumulative drift"
1160        );
1161    }
1162
1163    // --- Task 6 (B-sched, ledger SC-069): TimerAction::TriggerEvent -----
1164
1165    /// SC-069's wheel-level half: a `Resume` and a `TriggerEvent` entry armed
1166    /// at the same tick must both come back from `advance_to`, each with its
1167    /// own `action` variant intact, in FIFO arming order (SC-030's existing
1168    /// guarantee, now proven to hold across the two `TimerAction` variants
1169    /// too -- a wrong implementation that collapsed `TimerAction` back into
1170    /// a single shape, or that dropped/miscounted one of the two arms, would
1171    /// fail this).
1172    #[test]
1173    fn resume_and_trigger_entries_at_the_same_tick_both_fire_correctly() {
1174        let mut wheel = TimeWheel::new();
1175        let cell = StateCellId(77);
1176        // Armed in this order: the Resume entry first, then the trigger.
1177        wheel.arm(10, timer_entry(1));
1178        wheel.arm_trigger(10, cell);
1179
1180        let fired = wheel.advance_to(10);
1181        assert_eq!(
1182            fired.len(),
1183            2,
1184            "both entries armed at the same tick must fire together, neither \
1185             dropped nor merged"
1186        );
1187        match fired[0].action {
1188            TimerAction::Resume(act, region) => {
1189                assert_eq!(act.id, 1);
1190                assert_eq!(region, Region::Active);
1191            }
1192            TimerAction::TriggerEvent(c) => panic!(
1193                "expected the first fired entry (FIFO arming order) to be the \
1194                 Resume entry, got TriggerEvent({c:?})"
1195            ),
1196        }
1197        match fired[1].action {
1198            TimerAction::TriggerEvent(c) => assert_eq!(c, cell),
1199            TimerAction::Resume(act, _) => panic!(
1200                "expected the second fired entry to be the TriggerEvent entry \
1201                 (FIFO arming order preserved across the two TimerAction \
1202                 variants), got Resume(id={})",
1203                act.id
1204            ),
1205        }
1206
1207        let fired_again = wheel.advance_to(11);
1208        assert!(
1209            fired_again.is_empty(),
1210            "neither entry may fire a second time"
1211        );
1212    }
1213}