Skip to main content

proxima_clock/
anchor.rs

1use core::convert::Infallible;
2use core::fmt;
3use core::future::Future;
4
5use proxima_primitives::pipe::primitives::Pipe;
6
7use crate::seq_u64s::SeqU64s;
8use crate::ticks::Ticks;
9use crate::unix_nanos::UnixNanos;
10
11/// The `(ticks, unix_nanos)` correlation point a monotonic tick source is
12/// pinned to wall-clock time by — anchored once, re-anchorable later (an
13/// NTP/PTP discipline loop calling [`AnchorCell::set`] on a schedule).
14///
15/// This is the general, tick-domain, `no_std` shape for correlating a
16/// monotonic counter with the wall clock. Two clocks read independently
17/// (say `Instant::now()` and a wall-clock read) share no anchor, so one
18/// `(ticks, unix_nanos)` pair is stored here and every conversion is
19/// measured from it. [`ToUnixNanos`] is the conversion; `AnchorConfig`
20/// (module `config`, feature `config`) is the serializable mirror of the initial pair.
21///
22/// Seqlock-protected rather than set-once: a
23/// caller that never re-anchors pays nothing extra (one seqlock read costs
24/// the same as a plain load on the happy path); a caller running an NTP/PTP
25/// discipline loop gets a real re-anchor primitive instead of needing to
26/// rebuild the whole pipeline. What is deliberately NOT here yet: a
27/// frequency-drift/skew-slope correction term (the discipline loop's
28/// *rate* correction, as opposed to its periodic *offset* correction).
29/// Re-anchoring alone already tracks a disciplined external time source at
30/// the loop's correction cadence; adding a continuous skew term is the
31/// next increment, and this shape does not need to change to add it — a
32/// third seqlock-protected field (parts-per-billion drift) would join
33/// `ticks`/`unix_nanos` in the pair without touching [`ToUnixNanos`]'s
34/// call site.
35pub struct AnchorCell {
36    pair: SeqU64s<2>,
37}
38
39impl AnchorCell {
40    /// Anchor `ticks` to `unix_nanos` now.
41    #[must_use]
42    pub fn new(ticks: Ticks, unix_nanos: UnixNanos) -> Self {
43        Self {
44            pair: SeqU64s::new([ticks.as_raw(), unix_nanos.as_nanos()]),
45        }
46    }
47
48    /// Re-anchor: pin a fresh `(ticks, unix_nanos)` correlation point. The
49    /// discipline operation — call this from an NTP/PTP correction loop
50    /// each time it resolves a fresh offset.
51    pub fn set(&self, ticks: Ticks, unix_nanos: UnixNanos) {
52        self.pair.store([ticks.as_raw(), unix_nanos.as_nanos()]);
53    }
54
55    /// The current `(ticks, unix_nanos)` anchor pair, read as one
56    /// consistent unit.
57    #[must_use]
58    pub fn get(&self) -> (Ticks, UnixNanos) {
59        let [ticks, unix_nanos] = self.pair.load();
60        (Ticks::from_raw(ticks), UnixNanos::from_nanos(unix_nanos))
61    }
62}
63
64// not derived: a derived impl reads each atomic half on its own, so a
65// `{:?}` racing a re-anchor can print a pair that was never stored.
66impl fmt::Debug for AnchorCell {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        let (ticks, unix_nanos) = self.get();
69        formatter
70            .debug_struct("AnchorCell")
71            .field("ticks", &ticks)
72            .field("unix_nanos", &unix_nanos)
73            .finish()
74    }
75}
76
77/// Converts [`Ticks`] to [`UnixNanos`] against a live, re-anchorable
78/// [`AnchorCell`] — the monotonic-to-wall-clock bridge, expressed as a
79/// plain [`Pipe`] transform (`In = Ticks, Out = UnixNanos`) instead of a
80/// bespoke wrapper type.
81///
82/// Compose it with any tick source via `.and_then`:
83///
84/// ```
85/// use proxima_clock::anchor::{AnchorCell, ToUnixNanos};
86/// use proxima_clock::ticks::Ticks;
87/// use proxima_clock::unix_nanos::UnixNanos;
88/// use proxima_primitives::block_on;
89/// use proxima_primitives::pipe::ext::PipeExt;
90/// use proxima_primitives::pipe::primitives::Pipe;
91/// use core::convert::Infallible;
92/// use core::future::Future;
93///
94/// struct HardwareTicks(Ticks);
95/// impl Pipe for HardwareTicks {
96///     type In = ();
97///     type Out = Ticks;
98///     type Err = Infallible;
99///     fn call(&self, (): ()) -> impl Future<Output = Result<Ticks, Infallible>> {
100///         let ticks = self.0;
101///         async move { Ok(ticks) }
102///     }
103/// }
104///
105/// let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(1_753_500_000_000_000_000));
106/// let wall_clock = HardwareTicks(Ticks::from_raw(24_000_000))
107///     .and_then(ToUnixNanos::new(&anchor, 24_000_000));
108///
109/// // `wall_clock` is itself `impl Pipe<In = (), Out = UnixNanos, Err = Infallible>` —
110/// // no `WallClock<C>` wrapper type; the transform stage IS the bridge.
111/// let now = block_on(wall_clock.call(())).expect("a tick read never fails");
112/// assert_eq!(now, UnixNanos::from_nanos(1_753_500_001_000_000_000));
113/// ```
114///
115/// `frequency_hz` is supplied once at construction (a hardware clock's
116/// nominal rate is fixed for its lifetime — a PTP discipline loop adjusts
117/// [`AnchorCell`]'s offset, not this rate); it is not re-read per call.
118#[derive(Debug, Clone, Copy)]
119pub struct ToUnixNanos<'anchor> {
120    anchor: &'anchor AnchorCell,
121    frequency_hz: u64,
122}
123
124impl<'anchor> ToUnixNanos<'anchor> {
125    /// Build the conversion stage against `anchor`, at `frequency_hz`
126    /// (must be > 0 — see [`ToUnixNanos::call`]'s doc for the zero-frequency
127    /// clamp).
128    #[must_use]
129    pub const fn new(anchor: &'anchor AnchorCell, frequency_hz: u64) -> Self {
130        Self {
131            anchor,
132            frequency_hz,
133        }
134    }
135}
136
137impl Pipe for ToUnixNanos<'_> {
138    type In = Ticks;
139    type Out = UnixNanos;
140    type Err = Infallible;
141
142    /// `anchor_unix_nanos + (delta_ticks * 1_000_000_000 / frequency_hz)`,
143    /// computed in `u128` so a large `delta_ticks * 1e9` product cannot
144    /// overflow `u64` before the division narrows it back down. `u128`
145    /// division lowers to a `compiler_builtins` soft-division call on
146    /// 32-bit MCU targets (no native 64x64-bit or wider divide instruction)
147    /// — accepted here because this conversion runs once per read at the
148    /// edge (an export, a log timestamp, an NTP correction check), never
149    /// in the hot tick-counting loop where ticks stay raw `u64` all the
150    /// way through.
151    ///
152    /// `delta_ticks` uses [`Ticks::wrapping_sub`], so a free-running
153    /// counter wrapping past `u64::MAX` between anchor and read still
154    /// converts correctly. A `frequency_hz` of `0` (an implementor
155    /// contract violation — no real oscillator runs at 0 Hz) is clamped
156    /// to `1` rather than dividing by zero and panicking: a bare-metal
157    /// caller may have no panic handler configured to unwind or abort
158    /// safely, so this conversion stays total.
159    fn call(&self, ticks: Ticks) -> impl Future<Output = Result<UnixNanos, Infallible>> {
160        let (anchor_ticks, anchor_unix_nanos) = self.anchor.get();
161        let delta_ticks = ticks.wrapping_sub(anchor_ticks);
162        let frequency_hz = u128::from(self.frequency_hz.max(1));
163        let delta_nanos = u128::from(delta_ticks) * 1_000_000_000 / frequency_hz;
164        let unix_nanos = u128::from(anchor_unix_nanos.as_nanos()).saturating_add(delta_nanos);
165        let unix_nanos = UnixNanos::from_nanos(u64::try_from(unix_nanos).unwrap_or(u64::MAX));
166        async move { Ok(unix_nanos) }
167    }
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used)]
172mod tests {
173    use super::{AnchorCell, ToUnixNanos};
174    use crate::ticks::Ticks;
175    use crate::unix_nanos::UnixNanos;
176    use proxima_primitives::block_on;
177    use proxima_primitives::pipe::primitives::Pipe;
178
179    // 24 MHz: the ARM generic timer's real rate, and one that does not
180    // divide evenly into 1e9 (1_000_000_000 / 24_000_000 = 41.66...) — the
181    // conversion's rounding is genuinely exercised, not hidden by a tidy
182    // power-of-ten frequency.
183    const ARM_GENERIC_TIMER_HZ: u64 = 24_000_000;
184    const PTP_HARDWARE_CLOCK_HZ: u64 = 100_000_000;
185    const TSC_NOMINAL_HZ: u64 = 1_000_000_000;
186    const PRIME_WHEEL_HZ: u64 = 1_000;
187
188    fn convert(anchor: &AnchorCell, frequency_hz: u64, ticks: Ticks) -> UnixNanos {
189        block_on(Pipe::call(&ToUnixNanos::new(anchor, frequency_hz), ticks))
190            .expect("conversion never fails")
191    }
192
193    #[test]
194    fn at_the_anchor_point_wall_clock_equals_the_anchor() {
195        let anchor = AnchorCell::new(
196            Ticks::from_raw(0),
197            UnixNanos::from_nanos(1_753_500_000_000_000_000),
198        );
199
200        let now = convert(&anchor, ARM_GENERIC_TIMER_HZ, Ticks::from_raw(0));
201
202        assert_eq!(now, UnixNanos::from_nanos(1_753_500_000_000_000_000));
203    }
204
205    #[test]
206    fn arm_generic_timer_24mhz_one_second_of_ticks_advances_one_second() {
207        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
208
209        let one_second_later = convert(
210            &anchor,
211            ARM_GENERIC_TIMER_HZ,
212            Ticks::from_raw(ARM_GENERIC_TIMER_HZ),
213        );
214
215        assert_eq!(one_second_later, UnixNanos::from_nanos(1_000_000_000));
216    }
217
218    #[test]
219    fn arm_generic_timer_24mhz_rounding_is_exercised_not_hidden() {
220        // one tick at 24 MHz is 1_000_000_000 / 24_000_000 = 41.666... ns;
221        // integer division truncates to 41, proving the truncation
222        // actually happens instead of a power-of-ten frequency (where the
223        // division is always exact) masking it.
224        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
225
226        let one_tick_later = convert(&anchor, ARM_GENERIC_TIMER_HZ, Ticks::from_raw(1));
227
228        assert_eq!(one_tick_later, UnixNanos::from_nanos(41));
229    }
230
231    #[test]
232    fn ptp_hardware_clock_100mhz_one_millisecond_of_ticks() {
233        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
234
235        let one_millisecond_later =
236            convert(&anchor, PTP_HARDWARE_CLOCK_HZ, Ticks::from_raw(100_000));
237
238        assert_eq!(one_millisecond_later, UnixNanos::from_nanos(1_000_000));
239    }
240
241    #[test]
242    fn tsc_nominal_1ghz_ticks_equal_nanos_1to1() {
243        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
244
245        let converted = convert(&anchor, TSC_NOMINAL_HZ, Ticks::from_raw(1_234_567));
246
247        assert_eq!(converted, UnixNanos::from_nanos(1_234_567));
248    }
249
250    #[test]
251    fn prime_wheel_1khz_matches_the_wheels_millisecond_tick() {
252        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
253
254        let converted = convert(&anchor, PRIME_WHEEL_HZ, Ticks::from_raw(5_000));
255
256        assert_eq!(
257            converted,
258            UnixNanos::from_nanos(5_000_000_000),
259            "5000 ticks at 1000 Hz is 5 seconds, matching prime's ms-resolution wheel"
260        );
261    }
262
263    #[test]
264    fn counter_wraparound_still_converts_correctly() {
265        let anchor = AnchorCell::new(Ticks::from_raw(u64::MAX - 9), UnixNanos::from_nanos(0));
266
267        // the counter wraps past u64::MAX and comes back around to 10:
268        // 10 ticks to wrap (9 -> u64::MAX then +1) plus 10 more = 20 ticks
269        // elapsed, at 1 GHz nominal that is 20 ns.
270        let after_wrap = convert(&anchor, TSC_NOMINAL_HZ, Ticks::from_raw(10));
271
272        assert_eq!(after_wrap, UnixNanos::from_nanos(20));
273    }
274
275    #[test]
276    fn re_anchoring_disciplines_subsequent_reads_without_rebuilding_the_pipeline() {
277        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
278        let stage = ToUnixNanos::new(&anchor, TSC_NOMINAL_HZ);
279
280        let before_discipline =
281            block_on(Pipe::call(&stage, Ticks::from_raw(1_000))).expect("conversion never fails");
282        assert_eq!(before_discipline, UnixNanos::from_nanos(1_000));
283
284        // an NTP-style correction resolves a fresh offset: the same ticks
285        // domain is now known to correspond to a different wall-clock
286        // instant. re-anchoring is exactly this: no new pipeline, no new
287        // `ToUnixNanos` — `stage` observes the discipline immediately.
288        anchor.set(Ticks::from_raw(1_000), UnixNanos::from_nanos(50_000_000));
289
290        let after_discipline =
291            block_on(Pipe::call(&stage, Ticks::from_raw(2_000))).expect("conversion never fails");
292        assert_eq!(after_discipline, UnixNanos::from_nanos(50_001_000));
293    }
294
295    #[test]
296    fn zero_frequency_clamps_instead_of_dividing_by_zero() {
297        let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
298
299        let converted = convert(&anchor, 0, Ticks::from_raw(3));
300
301        assert_eq!(
302            converted,
303            UnixNanos::from_nanos(3_000_000_000),
304            "frequency_hz=0 clamps to 1 Hz rather than panicking on divide-by-zero"
305        );
306    }
307
308    // proptest needs an allocator and a test harness, so the properties are
309    // std-tier; the arithmetic they cover is `core`-only.
310    #[cfg(feature = "std")]
311    mod properties {
312        use super::{
313            AnchorCell, Pipe, TSC_NOMINAL_HZ, Ticks, ToUnixNanos, UnixNanos, block_on, convert,
314        };
315        use proptest::prelude::{ProptestConfig, any, prop_assert, prop_assert_eq, proptest};
316
317        /// 1 Hz up to ten times a nominal TSC. Drawing a frequency from the
318        /// whole `u64` space instead makes every property vacuous: at ~9e18 Hz
319        /// the elapsed nanosecond term rounds to 0 and the conversion
320        /// degenerates to the anchor, which a mutation of the saturation arm
321        /// survives unnoticed (measured — three properties went green against
322        /// a `u64::MAX` -> `0` mutation until this range replaced `any`). The
323        /// 0 Hz contract violation is owned deterministically by
324        /// [`super::zero_frequency_clamps_instead_of_dividing_by_zero`]
325        /// rather than left to a draw.
326        const PLAUSIBLE_COUNTER_HZ: core::ops::RangeInclusive<u64> = 1..=10_000_000_000;
327
328        proptest! {
329            #![proptest_config(ProptestConfig::with_cases(512))]
330
331            /// Reading the anchor's own tick value must reproduce the anchor's
332            /// own wall-clock value exactly, whatever the counter frequency —
333            /// the fixed point the whole conversion is defined around.
334            #[test]
335            fn the_anchor_point_is_a_fixed_point(
336                anchor_ticks in any::<u64>(),
337                anchor_nanos in any::<u64>(),
338                frequency_hz in PLAUSIBLE_COUNTER_HZ,
339            ) {
340                let anchor = AnchorCell::new(
341                    Ticks::from_raw(anchor_ticks),
342                    UnixNanos::from_nanos(anchor_nanos),
343                );
344
345                let converted = convert(&anchor, frequency_hz, Ticks::from_raw(anchor_ticks));
346
347                prop_assert_eq!(converted, UnixNanos::from_nanos(anchor_nanos));
348            }
349
350            /// A clock never reads earlier than its own anchor: the `u128`
351            /// widening, the `saturating_add` and the `u64::MAX` narrowing
352            /// fallback all have to preserve that, for every input including
353            /// the ones that saturate.
354            #[test]
355            fn a_reading_never_precedes_the_anchor(
356                anchor_ticks in any::<u64>(),
357                anchor_nanos in any::<u64>(),
358                frequency_hz in PLAUSIBLE_COUNTER_HZ,
359                elapsed_ticks in any::<u64>(),
360            ) {
361                let anchor = AnchorCell::new(
362                    Ticks::from_raw(anchor_ticks),
363                    UnixNanos::from_nanos(anchor_nanos),
364                );
365                let read = Ticks::from_raw(anchor_ticks.wrapping_add(elapsed_ticks));
366
367                let converted = convert(&anchor, frequency_hz, read);
368
369                prop_assert!(converted >= UnixNanos::from_nanos(anchor_nanos));
370            }
371
372            /// More elapsed ticks can never convert to an earlier instant, so a
373            /// span measured across two reads can never come out negative.
374            #[test]
375            fn more_elapsed_ticks_never_convert_backwards(
376                anchor_ticks in any::<u64>(),
377                anchor_nanos in any::<u64>(),
378                frequency_hz in PLAUSIBLE_COUNTER_HZ,
379                first_elapsed in any::<u64>(),
380                second_elapsed in any::<u64>(),
381            ) {
382                let (earlier, later) = if first_elapsed <= second_elapsed {
383                    (first_elapsed, second_elapsed)
384                } else {
385                    (second_elapsed, first_elapsed)
386                };
387                let anchor = AnchorCell::new(
388                    Ticks::from_raw(anchor_ticks),
389                    UnixNanos::from_nanos(anchor_nanos),
390                );
391
392                let earlier_reading = convert(
393                    &anchor,
394                    frequency_hz,
395                    Ticks::from_raw(anchor_ticks.wrapping_add(earlier)),
396                );
397                let later_reading = convert(
398                    &anchor,
399                    frequency_hz,
400                    Ticks::from_raw(anchor_ticks.wrapping_add(later)),
401                );
402
403                prop_assert!(earlier_reading <= later_reading);
404            }
405
406            /// At 1 GHz a tick IS a nanosecond, so the whole conversion must
407            /// reduce to `anchor.saturating_add(elapsed)` with no rounding
408            /// term anywhere — the oracle for the general case's arithmetic.
409            #[test]
410            fn a_1ghz_counter_converts_by_plain_saturating_addition(
411                anchor_ticks in any::<u64>(),
412                anchor_nanos in any::<u64>(),
413                elapsed_ticks in any::<u64>(),
414            ) {
415                let anchor = AnchorCell::new(
416                    Ticks::from_raw(anchor_ticks),
417                    UnixNanos::from_nanos(anchor_nanos),
418                );
419                let read = Ticks::from_raw(anchor_ticks.wrapping_add(elapsed_ticks));
420
421                let converted = convert(&anchor, TSC_NOMINAL_HZ, read);
422
423                prop_assert_eq!(
424                    converted,
425                    UnixNanos::from_nanos(anchor_nanos.saturating_add(elapsed_ticks))
426                );
427            }
428
429            /// The stage holds a borrow, not a snapshot: whatever a discipline
430            /// loop last stored is what the next reading is measured against.
431            #[test]
432            fn re_anchoring_is_observed_by_a_stage_built_beforehand(
433                first_nanos in any::<u64>(),
434                second_nanos in any::<u64>(),
435            ) {
436                let anchor = AnchorCell::new(Ticks::ZERO, UnixNanos::from_nanos(first_nanos));
437                let stage = ToUnixNanos::new(&anchor, TSC_NOMINAL_HZ);
438
439                anchor.set(Ticks::ZERO, UnixNanos::from_nanos(second_nanos));
440                let reading = block_on(Pipe::call(&stage, Ticks::ZERO))
441                    .expect("conversion never fails");
442
443                prop_assert_eq!(reading, UnixNanos::from_nanos(second_nanos));
444            }
445        }
446    }
447
448    // `format!` needs an allocator, so the rendering assertions are std-tier;
449    // the `Debug` impl itself is `core`-only and compiles on the floor.
450    #[cfg(feature = "std")]
451    mod rendering {
452        use super::{AnchorCell, Ticks, UnixNanos};
453
454        #[test]
455        fn debug_renders_the_live_anchor_not_the_constructed_one() {
456            let anchor = AnchorCell::new(Ticks::from_raw(0), UnixNanos::from_nanos(0));
457
458            anchor.set(Ticks::from_raw(1_000), UnixNanos::from_nanos(50_000_000));
459
460            assert_eq!(
461                format!("{anchor:?}"),
462                "AnchorCell { ticks: Ticks(1000), unix_nanos: UnixNanos(50000000) }"
463            );
464        }
465    }
466}