chronix_analytics/preprocess/
pipeline.rs1use 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#[derive(Debug, Clone)]
10pub struct PreprocessResult {
11 pub values: Vec<f64>,
13 pub timestamps: Vec<i64>,
15 pub gaps_filled: usize,
17 pub drift_report: Option<DriftReport>,
19}
20
21#[derive(Debug, thiserror::Error)]
23pub enum PreprocessError {
24 #[error("expected interval must be positive, got {0}")]
26 InvalidInterval(i64),
27
28 #[error("timestamps and values must have equal length: ts={ts_len}, vals={vals_len}")]
30 LengthMismatch {
31 ts_len: usize,
33 vals_len: usize,
35 },
36
37 #[error("invalid smoother alpha: must be in (0, 1], got {0}")]
39 InvalidAlpha(f64),
40
41 #[error("invalid smoother window: must be > 0")]
43 InvalidWindow,
44
45 #[error(transparent)]
47 Interpolation(#[from] crate::preprocess::interpolation::InterpolationError),
48}
49
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PreprocessConfig {
53 pub expected_interval_ns: i64,
55 #[serde(default = "default_tolerance")]
57 pub gap_tolerance: f64,
58 pub interpolation: Option<Interpolator>,
60 pub smoothing: Option<Smoother>,
62 pub resample: Option<ResampleConfig>,
64 #[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, gap_tolerance: 1.5,
78 interpolation: None,
79 smoothing: None,
80 resample: None,
81 clock_drift: ClockDriftStrategy::None,
82 }
83 }
84}
85
86pub struct PreprocessPipeline;
88
89impl PreprocessPipeline {
90 pub fn run(
100 config: &PreprocessConfig,
101 timestamps: &[i64],
102 values: &[f64],
103 ) -> Result<PreprocessResult, PreprocessError> {
104 let _start = std::time::Instant::now();
105 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 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 if config.clock_drift != ClockDriftStrategy::None {
146 let (corrected_ts, report) = ClockDriftDetector::correct(
147 ¤t_ts,
148 config.expected_interval_ns,
149 config.clock_drift,
150 );
151 current_ts = corrected_ts;
152 drift_report = Some(report);
153 }
154
155 if let Some(ref interpolator) = config.interpolation {
157 let (new_ts, new_vals, filled, skipped) =
158 interpolator.fill(¤t_ts, ¤t_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 if let Some(ref smoother) = config.smoothing {
169 current_vals = smoother.smooth(¤t_vals);
170 }
171
172 if let Some(ref resample_config) = config.resample {
174 let (new_ts, new_vals) =
175 Resampler::resample(¤t_ts, ¤t_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]); 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]); 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]; 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 for i in 1..result.timestamps.len() {
300 assert!(result.timestamps[i] > result.timestamps[i - 1]);
301 }
302 }
303}