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}