Skip to main content

chronix_analytics/preprocess/
gap.rs

1//! Gap detection in time-series data.
2
3/// Detects gaps in a sorted timestamp column where the interval exceeds
4/// `expected_interval × tolerance`.
5pub struct GapDetector {
6    /// Expected interval between consecutive timestamps (nanoseconds).
7    pub expected_interval_ns: i64,
8    /// Tolerance multiplier — gap is detected when interval > expected × tolerance.
9    pub tolerance: f64,
10}
11
12/// A detected gap in the time series.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Gap {
15    /// Index of the point before the gap.
16    pub before_idx: usize,
17    /// Index of the point after the gap.
18    pub after_idx: usize,
19    /// Timestamp of the point before the gap.
20    pub before_ts: i64,
21    /// Timestamp of the point after the gap.
22    pub after_ts: i64,
23    /// Number of expected points missing in this gap.
24    pub missing_count: usize,
25}
26
27impl GapDetector {
28    /// Creates a new gap detector.
29    ///
30    /// # Arguments
31    /// - `expected_interval_ns` — expected interval between consecutive points (nanoseconds, must be > 0)
32    /// - `tolerance` — multiplier for gap threshold (e.g. 1.5 means gap if interval > 1.5× expected, must be > 0)
33    ///
34    /// # Errors
35    ///
36    /// Returns `None` if `expected_interval_ns <= 0` or `tolerance <= 0.0`.
37    pub fn new(expected_interval_ns: i64, tolerance: f64) -> Option<Self> {
38        if expected_interval_ns <= 0 || tolerance <= 0.0 {
39            return None;
40        }
41        Some(Self {
42            expected_interval_ns,
43            tolerance,
44        })
45    }
46
47    /// Detects all gaps in the given sorted timestamp array.
48    pub fn detect(&self, timestamps: &[i64]) -> Vec<Gap> {
49        if timestamps.len() < 2 {
50            return Vec::new();
51        }
52
53        let threshold = (self.expected_interval_ns as f64 * self.tolerance) as i64;
54        let mut gaps = Vec::new();
55
56        for i in 1..timestamps.len() {
57            let delta = timestamps[i] - timestamps[i - 1];
58            if delta > threshold {
59                let missing = ((delta as f64 / self.expected_interval_ns as f64).round() as usize)
60                    .saturating_sub(1);
61                if missing > 0 {
62                    gaps.push(Gap {
63                        before_idx: i - 1,
64                        after_idx: i,
65                        before_ts: timestamps[i - 1],
66                        after_ts: timestamps[i],
67                        missing_count: missing,
68                    });
69                }
70            }
71        }
72        gaps
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn no_gaps() {
82        let ts: Vec<i64> = (0..10).map(|i| i * 1_000_000_000).collect(); // 1s intervals
83        let detector = GapDetector::new(1_000_000_000, 1.5).unwrap();
84        assert!(detector.detect(&ts).is_empty());
85    }
86
87    #[test]
88    fn single_gap() {
89        // 0, 1, 2, 5, 6 (gap of 3s between index 2 and 3)
90        let ts = vec![
91            0,
92            1_000_000_000,
93            2_000_000_000,
94            5_000_000_000,
95            6_000_000_000,
96        ];
97        let detector = GapDetector::new(1_000_000_000, 1.5).unwrap();
98        let gaps = detector.detect(&ts);
99        assert_eq!(gaps.len(), 1);
100        assert_eq!(gaps[0].before_idx, 2);
101        assert_eq!(gaps[0].after_idx, 3);
102        assert_eq!(gaps[0].missing_count, 2);
103    }
104
105    #[test]
106    fn multiple_gaps() {
107        let ts = vec![
108            0,
109            1_000_000_000,
110            5_000_000_000,
111            6_000_000_000,
112            10_000_000_000,
113        ];
114        let detector = GapDetector::new(1_000_000_000, 1.5).unwrap();
115        let gaps = detector.detect(&ts);
116        assert_eq!(gaps.len(), 2);
117    }
118
119    #[test]
120    fn empty_and_single() {
121        let detector = GapDetector::new(1_000_000_000, 1.5).unwrap();
122        assert!(detector.detect(&[]).is_empty());
123        assert!(detector.detect(&[42]).is_empty());
124    }
125
126    #[test]
127    fn invalid_params_return_none() {
128        assert!(GapDetector::new(0, 1.5).is_none());
129        assert!(GapDetector::new(-1, 1.5).is_none());
130        assert!(GapDetector::new(1_000, 0.0).is_none());
131        assert!(GapDetector::new(1_000, -1.0).is_none());
132    }
133}