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}