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}