Skip to main content

triblespace_core/
clock.rs

1//! Virtualizable time — the clock seam for deterministic simulation.
2//!
3//! Every time read that can influence persisted facts or protocol
4//! behavior goes through this module
5//! instead of `std::time::Instant::now()` / `hifitime::Epoch::now()`.
6//! In production the default [`Source::Real`] is a thin shim over
7//! those. Under simulation, [`install_virtual`] swaps in a
8//! [`VirtualClock`] that only moves when the simulator's
9//! discrete-event scheduler advances it — so cooldown expiry, renewal
10//! windows, and rebroadcast ticks become deterministic functions of
11//! the event schedule rather than of wall time.
12//!
13//! Two kinds of time, deliberately distinct:
14//!
15//! - [`mono_now`] → [`Mono`]: monotonic nanoseconds since an arbitrary
16//!   per-process origin. Replaces `std::time::Instant` for durations
17//!   and timeouts (redispatch cooldowns, quiescence tracking, the
18//!   gossip rebroadcast period). `Mono` is plain data (`u64` ns) so it
19//!   can cross thread and serialization boundaries freely, which
20//!   `Instant` cannot.
21//! - [`epoch_now`] → `hifitime::Epoch`: wall-clock TAI time. Used
22//!   where the *absolute* date matters and ends up in persisted facts:
23//!   cap expiry checks, renewal-policy timestamps, retraction marks.
24//!
25//! A discrete-event simulation has exactly one global timeline, so the
26//! source is process-global rather than per-node. Per-node clock skew
27//! (pre-mortem #47) is modeled *above* this seam — a skewed node adds
28//! its offset at the call site — keeping the substrate simple.
29//!
30//! The source is a `OnceLock`: it can be installed at most once, before
31//! first use, and stays for the process lifetime. Simulation tests live
32//! in their own integration-test binaries (one process each), so a
33//! global install doesn't leak across tests. `tokio::time::sleep` is
34//! NOT routed through here — simulation runtimes use
35//! `tokio::runtime::Builder::new_current_thread().start_paused(true)`,
36//! whose auto-advance handles sleeps; this module covers the
37//! *measurements* tokio can't see.
38
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41use std::sync::OnceLock;
42use std::time::Duration;
43
44/// A monotonic instant: nanoseconds since the process clock origin.
45///
46/// Plain data replacement for `std::time::Instant`. Ordering and
47/// arithmetic are exactly u64-ns ordering; the origin is arbitrary
48/// (process start for the real clock, simulation start for a virtual
49/// one) so only differences are meaningful.
50#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub struct Mono(u64);
52
53impl Mono {
54    /// Nanoseconds since the clock origin. Exposed for logging and
55    /// for simulators that want to inspect the raw timeline.
56    pub fn as_nanos(self) -> u64 {
57        self.0
58    }
59
60    /// Duration from `earlier` to `self`, saturating to zero if
61    /// `earlier` is actually later (mirrors
62    /// `Instant::saturating_duration_since`).
63    pub fn duration_since(self, earlier: Mono) -> Duration {
64        Duration::from_nanos(self.0.saturating_sub(earlier.0))
65    }
66
67    /// Duration from `self` to the current clock reading.
68    pub fn elapsed(self) -> Duration {
69        mono_now().duration_since(self)
70    }
71}
72
73/// A virtual clock for simulation: time is a counter the scheduler
74/// advances, plus a fixed wall-clock base so `epoch_now` stays
75/// meaningful for expiry math.
76pub struct VirtualClock {
77    /// Virtual nanoseconds since simulation start.
78    now_ns: AtomicU64,
79    /// Wall-clock (TAI) instant corresponding to virtual zero.
80    epoch_base: hifitime::Epoch,
81}
82
83impl VirtualClock {
84    /// A virtual clock starting at `epoch_base` (the simulated wall
85    /// time at virtual zero).
86    pub fn new(epoch_base: hifitime::Epoch) -> Arc<Self> {
87        Arc::new(Self {
88            now_ns: AtomicU64::new(0),
89            epoch_base,
90        })
91    }
92
93    /// Advance virtual time by `d`. Called only by the simulation
94    /// scheduler, between event deliveries.
95    pub fn advance(&self, d: Duration) {
96        self.now_ns
97            .fetch_add(d.as_nanos() as u64, Ordering::SeqCst);
98    }
99
100    /// Current virtual nanoseconds.
101    pub fn now_ns(&self) -> u64 {
102        self.now_ns.load(Ordering::SeqCst)
103    }
104
105    /// Rewind virtual time to zero. ONLY sound between independent
106    /// simulation runs in one process (each run constructs its whole
107    /// world fresh, so no live state carries `Mono` values across the
108    /// reset). Lets a test binary execute the same seeded scenario
109    /// twice and get bit-identical wall-clock-dependent artifacts
110    /// (commit timestamps, cap expiries) — the determinism-replay
111    /// contract.
112    pub fn reset(&self) {
113        self.now_ns.store(0, Ordering::SeqCst);
114    }
115}
116
117enum Source {
118    Real {
119        origin: std::time::Instant,
120    },
121    Virtual(Arc<VirtualClock>),
122}
123
124static SOURCE: OnceLock<Source> = OnceLock::new();
125
126fn source() -> &'static Source {
127    SOURCE.get_or_init(|| Source::Real {
128        origin: std::time::Instant::now(),
129    })
130}
131
132/// Install a virtual clock as the process-wide time source.
133///
134/// Must run before the first time read anywhere in the process —
135/// returns `Err(())` if a source (real or virtual) is already
136/// installed. Simulation harnesses call this first thing in `main`/
137/// the test body.
138pub fn install_virtual(clock: Arc<VirtualClock>) -> Result<(), ()> {
139    SOURCE.set(Source::Virtual(clock)).map_err(|_| ())
140}
141
142/// Current monotonic instant.
143pub fn mono_now() -> Mono {
144    match source() {
145        Source::Real { origin } => Mono(origin.elapsed().as_nanos() as u64),
146        Source::Virtual(vc) => Mono(vc.now_ns()),
147    }
148}
149
150/// Current wall-clock instant (TAI).
151///
152/// Real source: `hifitime::Epoch::now()` — panics only if the system
153/// clock is unreadable, which is unrecoverable misconfiguration.
154/// Virtual source: `epoch_base + virtual elapsed`.
155pub fn epoch_now() -> hifitime::Epoch {
156    match source() {
157        Source::Real { .. } => {
158            hifitime::Epoch::now().expect("system wall clock unreadable")
159        }
160        Source::Virtual(vc) => {
161            vc.epoch_base
162                + hifitime::Duration::from_total_nanoseconds(vc.now_ns() as i128)
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    // NOTE: install_virtual is process-global, and unit tests share a
172    // process — so these tests only exercise the real source and the
173    // VirtualClock struct in isolation. End-to-end virtual-time
174    // behavior is covered by the simulation integration tests (one
175    // process each).
176
177    #[test]
178    fn mono_is_monotonic() {
179        let a = mono_now();
180        let b = mono_now();
181        assert!(b >= a);
182        assert_eq!(b.duration_since(a), b.duration_since(a));
183    }
184
185    #[test]
186    fn duration_since_saturates() {
187        let a = Mono(100);
188        let b = Mono(50);
189        assert_eq!(b.duration_since(a), Duration::ZERO);
190        assert_eq!(a.duration_since(b), Duration::from_nanos(50));
191    }
192
193    #[test]
194    fn virtual_clock_advances() {
195        let vc = VirtualClock::new(hifitime::Epoch::from_tai_seconds(0.0));
196        assert_eq!(vc.now_ns(), 0);
197        vc.advance(Duration::from_millis(5));
198        assert_eq!(vc.now_ns(), 5_000_000);
199    }
200}