Skip to main content

subetha_cxc/
phase_estimator.rs

1//! Consumer-local arrival-phase estimator for predictive waiting.
2//!
3//! A blocking consumer normally parks on a doorbell and pays a
4//! park/wake syscall round-trip per item. When the producer arrives
5//! on a regular cadence, the consumer can instead PREDICT the next
6//! arrival and spin through a small guard band at exactly that
7//! moment, catching the item by polling and skipping the syscall.
8//! [`PhaseEstimator`] is the prediction: it tracks the inter-arrival
9//! period (EWMA) and its coefficient of variation, and engages only
10//! when the cadence is regular enough that prediction beats the
11//! doorbell.
12//!
13//! Entirely consumer-local: one struct on the consumer's stack, no
14//! shared state, no atomics, O(1) per observed arrival. It is fed the
15//! arrival timestamps the consumer already has (the `Instant` at each
16//! successful pop), so it adds no new shared fields to the ring.
17//!
18//! The estimator is built on a monotonic [`Instant`] clock, so the
19//! TSC-wraparound hazard of a raw-counter estimator does not arise.
20
21use std::time::{Duration, Instant};
22
23/// Tuning for a [`PhaseEstimator`].
24#[derive(Debug, Clone, Copy)]
25pub struct PhaseConfig {
26    /// EWMA weight for the period and CV updates (higher = faster
27    /// adaptation, noisier estimate).
28    pub alpha: f64,
29    /// Engage prediction once the CV drops below this AND the
30    /// minimum sample count is met.
31    pub cv_engage: f64,
32    /// Disengage once the CV rises above this (hysteresis: strictly
33    /// greater than `cv_engage` so the mode cannot flap at one
34    /// threshold).
35    pub cv_disengage: f64,
36    /// Minimum observed inter-arrivals before prediction is eligible.
37    pub min_samples: u64,
38}
39
40impl Default for PhaseConfig {
41    fn default() -> Self {
42        Self {
43            alpha: 0.2,
44            cv_engage: 0.25,
45            cv_disengage: 0.40,
46            min_samples: 16,
47        }
48    }
49}
50
51/// Tracks the producer's arrival cadence from the consumer side.
52pub struct PhaseEstimator {
53    cfg: PhaseConfig,
54    /// EWMA inter-arrival period, in nanoseconds. `None` until the
55    /// first delta is observed.
56    period_ns: Option<f64>,
57    /// EWMA of the relative period error `|delta - period| / period`
58    /// - the coefficient of variation.
59    cv: f64,
60    /// Timestamp of the most recent arrival.
61    last_arrival: Option<Instant>,
62    /// Number of inter-arrivals (deltas) observed.
63    samples: u64,
64    engaged: bool,
65}
66
67impl PhaseEstimator {
68    pub fn new(cfg: PhaseConfig) -> Self {
69        Self {
70            cfg,
71            period_ns: None,
72            cv: 1.0,
73            last_arrival: None,
74            samples: 0,
75            engaged: false,
76        }
77    }
78
79    /// Record an arrival observed at `now`. O(1); updates the period
80    /// EWMA, the CV, the sample count, and the engaged state (with
81    /// hysteresis).
82    pub fn record(&mut self, now: Instant) {
83        if let Some(last) = self.last_arrival {
84            let delta_ns = now.saturating_duration_since(last).as_nanos() as f64;
85            match self.period_ns {
86                Some(p) if p > 0.0 => {
87                    let rel_err = (delta_ns - p).abs() / p;
88                    self.cv = (1.0 - self.cfg.alpha) * self.cv + self.cfg.alpha * rel_err;
89                    self.period_ns =
90                        Some((1.0 - self.cfg.alpha) * p + self.cfg.alpha * delta_ns);
91                }
92                _ => {
93                    // First delta seeds the period; CV stays at its
94                    // pessimistic initial value until a second delta
95                    // gives a comparison.
96                    self.period_ns = Some(delta_ns);
97                }
98            }
99            self.samples += 1;
100            self.update_engaged();
101        }
102        self.last_arrival = Some(now);
103    }
104
105    fn update_engaged(&mut self) {
106        if self.engaged {
107            if self.cv > self.cfg.cv_disengage || self.samples < self.cfg.min_samples {
108                self.engaged = false;
109            }
110        } else if self.cv < self.cfg.cv_engage && self.samples >= self.cfg.min_samples {
111            self.engaged = true;
112        }
113    }
114
115    /// Predicted timestamp of the next arrival, or `None` if no
116    /// period is known yet.
117    pub fn predict_next(&self) -> Option<Instant> {
118        match (self.last_arrival, self.period_ns) {
119            (Some(last), Some(p)) if p > 0.0 => {
120                Some(last + Duration::from_nanos(p as u64))
121            }
122            _ => None,
123        }
124    }
125
126    /// Whether prediction is currently engaged (regular enough
127    /// cadence, enough samples).
128    pub fn engaged(&self) -> bool {
129        self.engaged
130    }
131
132    /// Current EWMA period estimate.
133    pub fn period(&self) -> Option<Duration> {
134        self.period_ns.map(|p| Duration::from_nanos(p as u64))
135    }
136
137    /// Current coefficient-of-variation estimate.
138    pub fn cv(&self) -> f64 {
139        self.cv
140    }
141
142    /// Number of inter-arrivals observed.
143    pub fn samples(&self) -> u64 {
144        self.samples
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    /// Feed a synthetic arrival series built off one base instant.
153    fn feed(est: &mut PhaseEstimator, base: Instant, offsets_ns: &[u64]) {
154        for &off in offsets_ns {
155            est.record(base + Duration::from_nanos(off));
156        }
157    }
158
159    #[test]
160    fn perfect_period_converges_and_engages() {
161        let mut est = PhaseEstimator::new(PhaseConfig::default());
162        let base = Instant::now();
163        let offsets: Vec<u64> = (0..40).map(|i| i * 10_000).collect(); // exact 10us
164        feed(&mut est, base, &offsets);
165        assert!(est.engaged(), "a perfectly periodic series must engage");
166        let p = est.period().unwrap().as_nanos() as f64;
167        assert!((p - 10_000.0).abs() / 10_000.0 < 0.05,
168                "period within 5% of 10us, got {p}ns");
169        assert!(est.cv() < 0.05, "CV must be near zero, got {}", est.cv());
170    }
171
172    #[test]
173    fn mild_jitter_still_engages_within_5pct() {
174        let mut est = PhaseEstimator::new(PhaseConfig::default());
175        let base = Instant::now();
176        // 10us period with deterministic +/-5% jitter (CV ~ 0.05).
177        let mut t = 0u64;
178        let mut offsets = Vec::new();
179        for i in 0..64u64 {
180            let jit = if i % 2 == 0 { 9_500 } else { 10_500 };
181            t += jit;
182            offsets.push(t);
183        }
184        feed(&mut est, base, &offsets);
185        assert!(est.engaged(), "mild jitter must still engage");
186        let p = est.period().unwrap().as_nanos() as f64;
187        assert!((p - 10_000.0).abs() / 10_000.0 < 0.05,
188                "period within 5%, got {p}ns");
189    }
190
191    #[test]
192    fn high_variance_does_not_engage() {
193        let mut est = PhaseEstimator::new(PhaseConfig::default());
194        let base = Instant::now();
195        // Alternating 2us / 18us = mean 10us, CV ~ 0.8.
196        let mut t = 0u64;
197        let mut offsets = Vec::new();
198        for i in 0..64u64 {
199            t += if i % 2 == 0 { 2_000 } else { 18_000 };
200            offsets.push(t);
201        }
202        feed(&mut est, base, &offsets);
203        assert!(!est.engaged(), "high-variance cadence must not engage");
204    }
205
206    #[test]
207    fn disengages_when_cadence_breaks_down() {
208        let mut est = PhaseEstimator::new(PhaseConfig::default());
209        let base = Instant::now();
210        // First settle into a clean 10us cadence...
211        let clean: Vec<u64> = (0..40).map(|i| i * 10_000).collect();
212        feed(&mut est, base, &clean);
213        assert!(est.engaged());
214        // ...then a burst of wild jitter must disengage (hysteresis
215        // means it takes a few samples, not one).
216        let mut t = 40 * 10_000u64;
217        let mut chaos = Vec::new();
218        for i in 0..40u64 {
219            t += if i % 2 == 0 { 1_000 } else { 30_000 };
220            chaos.push(t);
221        }
222        feed(&mut est, base, &chaos);
223        assert!(!est.engaged(), "broken cadence must disengage");
224    }
225
226    #[test]
227    fn does_not_engage_before_min_samples() {
228        let cfg = PhaseConfig { min_samples: 20, ..PhaseConfig::default() };
229        let mut est = PhaseEstimator::new(cfg);
230        let base = Instant::now();
231        let few: Vec<u64> = (0..10).map(|i| i * 10_000).collect();
232        feed(&mut est, base, &few);
233        assert!(!est.engaged(), "must not engage before min_samples deltas");
234        assert!(est.samples() < 20);
235    }
236
237    #[test]
238    fn predict_next_is_last_plus_period() {
239        let mut est = PhaseEstimator::new(PhaseConfig::default());
240        let base = Instant::now();
241        let offsets: Vec<u64> = (0..40).map(|i| i * 10_000).collect();
242        feed(&mut est, base, &offsets);
243        let last = base + Duration::from_nanos(39 * 10_000);
244        let predicted = est.predict_next().unwrap();
245        let expected = last + est.period().unwrap();
246        let diff = predicted.saturating_duration_since(expected)
247            + expected.saturating_duration_since(predicted);
248        assert!(diff < Duration::from_nanos(100), "prediction = last + period");
249    }
250}