Skip to main content

chronix_analytics/preprocess/
pipeline.rs

1//! Preprocessing pipeline — composes preprocessing steps into configurable pipelines.
2
3use crate::preprocess::clock_drift::{ClockDriftDetector, ClockDriftStrategy, DriftReport};
4use crate::preprocess::interpolation::Interpolator;
5use crate::preprocess::resampling::{ResampleConfig, Resampler};
6use crate::preprocess::smoothing::Smoother;
7
8/// Result of a preprocessing pipeline run.
9#[derive(Debug, Clone)]
10pub struct PreprocessResult {
11    /// Output values.
12    pub values: Vec<f64>,
13    /// Output timestamps (nanoseconds).
14    pub timestamps: Vec<i64>,
15    /// Number of gaps filled during interpolation.
16    pub gaps_filled: usize,
17    /// Clock drift report (if drift correction was applied).
18    pub drift_report: Option<DriftReport>,
19}
20
21/// Preprocessing errors.
22#[derive(Debug, thiserror::Error)]
23pub enum PreprocessError {
24    /// The expected interval is not positive.
25    #[error("expected interval must be positive, got {0}")]
26    InvalidInterval(i64),
27
28    /// Timestamp and value arrays have different lengths.
29    #[error("timestamps and values must have equal length: ts={ts_len}, vals={vals_len}")]
30    LengthMismatch {
31        /// Length of the timestamp array.
32        ts_len: usize,
33        /// Length of the values array.
34        vals_len: usize,
35    },
36
37    /// The smoother alpha value is outside the valid range (0, 1].
38    #[error("invalid smoother alpha: must be in (0, 1], got {0}")]
39    InvalidAlpha(f64),
40
41    /// The smoother window size must be greater than zero.
42    #[error("invalid smoother window: must be > 0")]
43    InvalidWindow,
44
45    /// An interpolation error occurred.
46    #[error(transparent)]
47    Interpolation(#[from] crate::preprocess::interpolation::InterpolationError),
48}
49
50/// Configuration for the preprocessing pipeline.
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PreprocessConfig {
53    /// Expected interval between points (nanoseconds).
54    pub expected_interval_ns: i64,
55    /// Gap detection tolerance multiplier.
56    #[serde(default = "default_tolerance")]
57    pub gap_tolerance: f64,
58    /// Interpolation method (None = skip).
59    pub interpolation: Option<Interpolator>,
60    /// Smoothing method (None = skip).
61    pub smoothing: Option<Smoother>,
62    /// Resampling configuration (None = skip).
63    pub resample: Option<ResampleConfig>,
64    /// Clock drift correction strategy.
65    #[serde(default)]
66    pub clock_drift: ClockDriftStrategy,
67}
68
69fn default_tolerance() -> f64 {
70    1.5
71}
72
73impl Default for PreprocessConfig {
74    fn default() -> Self {
75        Self {
76            expected_interval_ns: 1_000_000_000, // 1 second
77            gap_tolerance: 1.5,
78            interpolation: None,
79            smoothing: None,
80            resample: None,
81            clock_drift: ClockDriftStrategy::None,
82        }
83    }
84}
85
86/// Preprocessing pipeline.
87pub struct PreprocessPipeline;
88
89impl PreprocessPipeline {
90    /// Run the preprocessing pipeline on the given data.
91    ///
92    /// Steps are applied in order:
93    /// 1. Clock drift correction
94    /// 2. Gap interpolation
95    /// 3. Smoothing
96    /// 4. Resampling
97    ///
98    /// Steps are skipped if not configured.
99    pub fn run(
100        config: &PreprocessConfig,
101        timestamps: &[i64],
102        values: &[f64],
103    ) -> Result<PreprocessResult, PreprocessError> {
104        let _start = std::time::Instant::now();
105        // Validate inputs
106        if timestamps.len() != values.len() {
107            return Err(PreprocessError::LengthMismatch {
108                ts_len: timestamps.len(),
109                vals_len: values.len(),
110            });
111        }
112        if config.expected_interval_ns <= 0 {
113            return Err(PreprocessError::InvalidInterval(
114                config.expected_interval_ns,
115            ));
116        }
117
118        // Validate smoother config
119        if let Some(ref smoother) = config.smoothing {
120            match smoother {
121                Smoother::Exponential { alpha } => {
122                    if *alpha <= 0.0 || *alpha > 1.0 {
123                        return Err(PreprocessError::InvalidAlpha(*alpha));
124                    }
125                }
126                Smoother::MovingAverage { window } => {
127                    if *window == 0 {
128                        return Err(PreprocessError::InvalidWindow);
129                    }
130                }
131                Smoother::WeightedMovingAverage { weights } => {
132                    if weights.is_empty() {
133                        return Err(PreprocessError::InvalidWindow);
134                    }
135                }
136            }
137        }
138
139        let mut current_ts = timestamps.to_vec();
140        let mut current_vals = values.to_vec();
141        let mut gaps_filled = 0usize;
142        let mut drift_report = None;
143
144        // Step 1: Clock drift correction
145        if config.clock_drift != ClockDriftStrategy::None {
146            let (corrected_ts, report) = ClockDriftDetector::correct(
147                &current_ts,
148                config.expected_interval_ns,
149                config.clock_drift,
150            );
151            current_ts = corrected_ts;
152            drift_report = Some(report);
153        }
154
155        // Step 2: Interpolation
156        if let Some(ref interpolator) = config.interpolation {
157            let (new_ts, new_vals, filled, skipped) =
158                interpolator.fill(&current_ts, &current_vals, config.expected_interval_ns)?;
159            current_ts = new_ts;
160            current_vals = new_vals;
161            gaps_filled = filled;
162            if skipped > 0 {
163                tracing::warn!(gaps_skipped = skipped, "some gaps were too large to fill");
164            }
165        }
166
167        // Step 3: Smoothing
168        if let Some(ref smoother) = config.smoothing {
169            current_vals = smoother.smooth(&current_vals);
170        }
171
172        // Step 4: Resampling
173        if let Some(ref resample_config) = config.resample {
174            let (new_ts, new_vals) =
175                Resampler::resample(&current_ts, &current_vals, resample_config);
176            current_ts = new_ts;
177            current_vals = new_vals;
178        }
179
180        metrics::histogram!("chronix_preprocess_duration_seconds")
181            .record(_start.elapsed().as_secs_f64());
182        Ok(PreprocessResult {
183            values: current_vals,
184            timestamps: current_ts,
185            gaps_filled,
186            drift_report,
187        })
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn make_ts(secs: &[i64]) -> Vec<i64> {
196        secs.iter().map(|&s| s * 1_000_000_000).collect()
197    }
198
199    #[test]
200    fn empty_config_passthrough() {
201        let ts = make_ts(&[0, 1, 2, 3]);
202        let vals = vec![1.0, 2.0, 3.0, 4.0];
203        let config = PreprocessConfig::default();
204        let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
205        assert_eq!(result.timestamps, ts);
206        assert_eq!(result.values, vals);
207        assert_eq!(result.gaps_filled, 0);
208        assert!(result.drift_report.is_none());
209    }
210
211    #[test]
212    fn interpolation_only() {
213        let ts = make_ts(&[0, 1, 4, 5]); // gap between 1 and 4
214        let vals = vec![10.0, 20.0, 50.0, 60.0];
215        let config = PreprocessConfig {
216            expected_interval_ns: 1_000_000_000,
217            interpolation: Some(Interpolator::Linear),
218            ..Default::default()
219        };
220        let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
221        assert_eq!(result.gaps_filled, 2);
222        assert_eq!(result.timestamps.len(), 6);
223    }
224
225    #[test]
226    fn smoothing_only() {
227        let ts = make_ts(&[0, 1, 2, 3, 4]);
228        let vals = vec![10.0, 20.0, 15.0, 25.0, 20.0];
229        let config = PreprocessConfig {
230            expected_interval_ns: 1_000_000_000,
231            smoothing: Some(Smoother::Exponential { alpha: 0.5 }),
232            ..Default::default()
233        };
234        let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
235        assert_eq!(result.values.len(), 5);
236        assert_eq!(result.gaps_filled, 0);
237    }
238
239    #[test]
240    fn full_pipeline() {
241        let ts = make_ts(&[0, 1, 4, 5, 6]); // gap between 1 and 4
242        let vals = vec![10.0, 20.0, 50.0, 60.0, 70.0];
243        let config = PreprocessConfig {
244            expected_interval_ns: 1_000_000_000,
245            interpolation: Some(Interpolator::Linear),
246            smoothing: Some(Smoother::MovingAverage { window: 3 }),
247            ..Default::default()
248        };
249        let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
250        assert!(result.gaps_filled > 0);
251        assert!(result.values.len() > vals.len());
252    }
253
254    #[test]
255    fn validation_length_mismatch() {
256        let ts = make_ts(&[0, 1, 2]);
257        let vals = vec![1.0, 2.0];
258        let config = PreprocessConfig::default();
259        assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
260    }
261
262    #[test]
263    fn validation_invalid_interval() {
264        let ts = make_ts(&[0, 1]);
265        let vals = vec![1.0, 2.0];
266        let config = PreprocessConfig {
267            expected_interval_ns: -1,
268            ..Default::default()
269        };
270        assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
271    }
272
273    #[test]
274    fn validation_invalid_alpha() {
275        let ts = make_ts(&[0, 1]);
276        let vals = vec![1.0, 2.0];
277        let config = PreprocessConfig {
278            expected_interval_ns: 1_000_000_000,
279            smoothing: Some(Smoother::Exponential { alpha: 0.0 }),
280            ..Default::default()
281        };
282        assert!(PreprocessPipeline::run(&config, &ts, &vals).is_err());
283    }
284
285    #[test]
286    fn with_clock_drift_correction() {
287        let ts = vec![0, 100, 50, 200, 300]; // backward at index 2
288        let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0];
289        let config = PreprocessConfig {
290            expected_interval_ns: 100,
291            clock_drift: ClockDriftStrategy::Monotonic,
292            ..Default::default()
293        };
294        let result = PreprocessPipeline::run(&config, &ts, &vals).unwrap();
295        assert!(result.drift_report.is_some());
296        let report = result.drift_report.unwrap();
297        assert!(report.corrections_applied > 0);
298        // Timestamps should be monotonically increasing
299        for i in 1..result.timestamps.len() {
300            assert!(result.timestamps[i] > result.timestamps[i - 1]);
301        }
302    }
303}