Skip to main content

chronix_analytics/preprocess/
clock_drift.rs

1//! Clock drift detection and correction for irregularly-sampled data.
2
3/// Strategy for clock drift correction.
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5pub enum ClockDriftStrategy {
6    /// No correction.
7    #[default]
8    None,
9    /// Enforce monotonically increasing timestamps — duplicate/backward stamps
10    /// are snapped to `last_ts + 1ns`.
11    Monotonic,
12    /// Detect gradual clock drift via regression on timestamp deltas and apply
13    /// linear correction to align with the expected interval.
14    ///
15    /// # Limitation
16    ///
17    /// The smooth correction fits a **linear** regression on timestamp
18    /// deltas (`delta[i] = a + b*i`) and blends toward the ideal grid
19    /// proportionally to drift magnitude.  This assumes clock drift is
20    /// approximately constant-rate (e.g. quartz oscillator frequency
21    /// offset).  **Non-linear** drift patterns — such as thermal-induced
22    /// frequency ramps or GPS-disciplined clock step corrections — are
23    /// not well modelled and may leave residual jitter.  For those cases
24    /// consider pre-processing with piecewise-linear or spline-based
25    /// correction before ingestion.
26    Smooth,
27}
28
29/// Report of clock drift detection and correction.
30#[derive(Debug, Clone)]
31pub struct DriftReport {
32    /// Maximum observed drift in nanoseconds.
33    pub max_drift_ns: i64,
34    /// Ranges of affected indices (start, end).
35    pub affected_ranges: Vec<(usize, usize)>,
36    /// Number of timestamps that were corrected.
37    pub corrections_applied: usize,
38}
39
40/// Detects and corrects clock drift in timestamp arrays.
41pub struct ClockDriftDetector;
42
43impl ClockDriftDetector {
44    /// Detect and correct clock drift according to the given strategy.
45    ///
46    /// Returns the corrected timestamps and a drift report.
47    pub fn correct(
48        timestamps: &[i64],
49        expected_interval_ns: i64,
50        strategy: ClockDriftStrategy,
51    ) -> (Vec<i64>, DriftReport) {
52        let result = match strategy {
53            ClockDriftStrategy::None => (
54                timestamps.to_vec(),
55                DriftReport {
56                    max_drift_ns: 0,
57                    affected_ranges: Vec::new(),
58                    corrections_applied: 0,
59                },
60            ),
61            ClockDriftStrategy::Monotonic => Self::monotonic(timestamps),
62            ClockDriftStrategy::Smooth => Self::smooth(timestamps, expected_interval_ns),
63        };
64        metrics::counter!("chronix_clock_drift_corrections_total")
65            .increment(result.1.corrections_applied as u64);
66        result
67    }
68
69    /// Monotonic enforcement: snap backward/duplicate timestamps forward.
70    fn monotonic(timestamps: &[i64]) -> (Vec<i64>, DriftReport) {
71        if timestamps.is_empty() {
72            return (
73                Vec::new(),
74                DriftReport {
75                    max_drift_ns: 0,
76                    affected_ranges: Vec::new(),
77                    corrections_applied: 0,
78                },
79            );
80        }
81
82        let mut corrected = Vec::with_capacity(timestamps.len());
83        corrected.push(timestamps[0]);
84
85        let mut max_drift: i64 = 0;
86        let mut corrections = 0usize;
87        let mut ranges: Vec<(usize, usize)> = Vec::new();
88        let mut in_range = false;
89        let mut range_start = 0usize;
90
91        for i in 1..timestamps.len() {
92            if timestamps[i] <= corrected[i - 1] {
93                let drift = corrected[i - 1] - timestamps[i] + 1;
94                max_drift = max_drift.max(drift);
95                corrected.push(corrected[i - 1] + 1);
96                corrections += 1;
97
98                if !in_range {
99                    range_start = i;
100                    in_range = true;
101                }
102            } else {
103                corrected.push(timestamps[i]);
104                if in_range {
105                    ranges.push((range_start, i - 1));
106                    in_range = false;
107                }
108            }
109        }
110
111        if in_range {
112            ranges.push((range_start, timestamps.len() - 1));
113        }
114
115        (
116            corrected,
117            DriftReport {
118                max_drift_ns: max_drift,
119                affected_ranges: ranges,
120                corrections_applied: corrections,
121            },
122        )
123    }
124
125    /// Smooth correction: detect gradual drift via regression on deltas,
126    /// apply linear correction.
127    fn smooth(timestamps: &[i64], expected_interval_ns: i64) -> (Vec<i64>, DriftReport) {
128        if timestamps.len() < 3 {
129            return (
130                timestamps.to_vec(),
131                DriftReport {
132                    max_drift_ns: 0,
133                    affected_ranges: Vec::new(),
134                    corrections_applied: 0,
135                },
136            );
137        }
138
139        let n = timestamps.len();
140        let deltas: Vec<f64> = timestamps
141            .windows(2)
142            .map(|w| (w[1] - w[0]) as f64)
143            .collect();
144
145        // Linear regression on deltas: delta[i] = a + b*i
146        // If b is significant, there's gradual drift
147        let n_d = deltas.len() as f64;
148        let sum_x: f64 = (0..deltas.len()).map(|i| i as f64).sum();
149        let sum_y: f64 = deltas.iter().sum();
150        let sum_xy: f64 = deltas.iter().enumerate().map(|(i, &d)| i as f64 * d).sum();
151        let sum_xx: f64 = (0..deltas.len()).map(|i| (i as f64).powi(2)).sum();
152
153        let denom = n_d * sum_xx - sum_x * sum_x;
154        if denom.abs() < 1e-10 {
155            return (
156                timestamps.to_vec(),
157                DriftReport {
158                    max_drift_ns: 0,
159                    affected_ranges: Vec::new(),
160                    corrections_applied: 0,
161                },
162            );
163        }
164
165        let slope = (n_d * sum_xy - sum_x * sum_y) / denom;
166        let expected = expected_interval_ns as f64;
167
168        // Only correct if drift rate is significant (> 0.1% per point)
169        if slope.abs() < expected * 0.001 {
170            return (
171                timestamps.to_vec(),
172                DriftReport {
173                    max_drift_ns: 0,
174                    affected_ranges: Vec::new(),
175                    corrections_applied: 0,
176                },
177            );
178        }
179
180        // Blend original and ideal timestamps proportionally to local drift magnitude.
181        // Timestamps with near-zero drift are preserved; those with large drift are
182        // pulled toward the ideal position.  The threshold for full correction is set
183        // to `expected_interval_ns` — drift ≥ 1 full interval → fully corrected.
184        let threshold = expected;
185        let mut corrected = Vec::with_capacity(n);
186        corrected.push(timestamps[0]);
187        let mut max_drift: i64 = 0;
188        for i in 1..n {
189            let ideal =
190                timestamps[0].saturating_add((i as i64).saturating_mul(expected_interval_ns));
191            let local_drift = (timestamps[i] - ideal).abs();
192            max_drift = max_drift.max(local_drift);
193            let alpha = if threshold > 0.0 {
194                (local_drift as f64 / threshold).clamp(0.0, 1.0)
195            } else {
196                0.0
197            };
198            // alpha=0 → keep original, alpha=1 → fully corrected
199            let blended =
200                ((1.0 - alpha) * timestamps[i] as f64 + alpha * ideal as f64).round() as i64;
201            corrected.push(blended);
202        }
203
204        // Count how many timestamps actually changed
205        let corrections = (0..n).filter(|&i| timestamps[i] != corrected[i]).count();
206
207        (
208            corrected,
209            DriftReport {
210                max_drift_ns: max_drift,
211                affected_ranges: if max_drift > 0 {
212                    vec![(0, n - 1)]
213                } else {
214                    Vec::new()
215                },
216                corrections_applied: corrections,
217            },
218        )
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn no_correction() {
228        let ts = vec![0, 100, 200, 300];
229        let (corrected, report) = ClockDriftDetector::correct(&ts, 100, ClockDriftStrategy::None);
230        assert_eq!(corrected, ts);
231        assert_eq!(report.corrections_applied, 0);
232    }
233
234    #[test]
235    fn monotonic_no_issue() {
236        let ts = vec![0, 100, 200, 300];
237        let (corrected, report) =
238            ClockDriftDetector::correct(&ts, 100, ClockDriftStrategy::Monotonic);
239        assert_eq!(corrected, ts);
240        assert_eq!(report.corrections_applied, 0);
241    }
242
243    #[test]
244    fn monotonic_backward_timestamps() {
245        let ts = vec![0, 100, 50, 200]; // 50 < 100 → snap forward
246        let (corrected, report) =
247            ClockDriftDetector::correct(&ts, 100, ClockDriftStrategy::Monotonic);
248        assert!(corrected[2] > corrected[1]); // monotonically increasing
249        assert_eq!(corrected[2], 101); // snapped to 100+1
250        assert_eq!(report.corrections_applied, 1);
251        assert_eq!(report.affected_ranges.len(), 1);
252    }
253
254    #[test]
255    fn monotonic_duplicate_timestamps() {
256        let ts = vec![0, 100, 100, 100, 200];
257        let (corrected, report) =
258            ClockDriftDetector::correct(&ts, 100, ClockDriftStrategy::Monotonic);
259        // Each duplicate gets snapped forward by 1
260        assert_eq!(corrected[2], 101);
261        assert_eq!(corrected[3], 102);
262        assert_eq!(report.corrections_applied, 2);
263    }
264
265    #[test]
266    fn smooth_gradual_drift() {
267        // Simulate gradual drift: expected interval 100ns, actual drifts by +10 each step
268        let expected = 100;
269        let ts: Vec<i64> = (0..20)
270            .map(|i| i * expected + i * i * 5) // quadratic drift
271            .collect();
272        let (corrected, report) =
273            ClockDriftDetector::correct(&ts, expected, ClockDriftStrategy::Smooth);
274        // Corrected timestamps should be closer to expected interval than originals
275        let original_var: f64 = ts
276            .windows(2)
277            .map(|w| ((w[1] - w[0]) as f64 - expected as f64).powi(2))
278            .sum();
279        let corrected_var: f64 = corrected
280            .windows(2)
281            .map(|w| ((w[1] - w[0]) as f64 - expected as f64).powi(2))
282            .sum();
283        assert!(
284            corrected_var < original_var,
285            "corrected intervals should have less variance"
286        );
287        assert!(report.corrections_applied > 0);
288    }
289
290    #[test]
291    fn smooth_no_drift() {
292        let ts: Vec<i64> = (0..10).map(|i| i * 1000).collect();
293        let (corrected, report) =
294            ClockDriftDetector::correct(&ts, 1000, ClockDriftStrategy::Smooth);
295        assert_eq!(corrected, ts);
296        assert_eq!(report.corrections_applied, 0);
297    }
298
299    #[test]
300    fn empty_timestamps() {
301        let (corrected, report) =
302            ClockDriftDetector::correct(&[], 100, ClockDriftStrategy::Monotonic);
303        assert!(corrected.is_empty());
304        assert_eq!(report.corrections_applied, 0);
305    }
306}