cvx_analytics/
temporal_ml.rs1use cvx_core::error::AnalyticsError;
25
26pub trait TemporalOps {
31 fn extract_features(&self, trajectory: &[(i64, &[f32])]) -> Result<Vec<f32>, AnalyticsError>;
35
36 fn feature_dim(&self, input_dim: usize) -> usize;
38
39 fn name(&self) -> &str;
41}
42
43#[derive(Debug, Clone)]
45pub struct AnalyticConfig {
46 pub scales: Vec<i64>,
48 pub cp_threshold: f32,
50 pub cp_smoothing: usize,
52}
53
54impl Default for AnalyticConfig {
55 fn default() -> Self {
56 Self {
57 scales: vec![1_000_000, 10_000_000, 100_000_000], cp_threshold: 3.0,
59 cp_smoothing: 5,
60 }
61 }
62}
63
64pub struct AnalyticBackend {
69 config: AnalyticConfig,
70}
71
72impl AnalyticBackend {
73 pub fn new() -> Self {
75 Self {
76 config: AnalyticConfig::default(),
77 }
78 }
79
80 pub fn with_config(config: AnalyticConfig) -> Self {
82 Self { config }
83 }
84}
85
86impl Default for AnalyticBackend {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92impl TemporalOps for AnalyticBackend {
93 fn extract_features(&self, trajectory: &[(i64, &[f32])]) -> Result<Vec<f32>, AnalyticsError> {
94 if trajectory.len() < 2 {
95 return Err(AnalyticsError::InsufficientData {
96 needed: 2,
97 have: trajectory.len(),
98 });
99 }
100
101 let dim = trajectory[0].1.len();
102 let n = trajectory.len();
103 let n_scales = self.config.scales.len();
104 let total_features = 2 * dim + 2 + n_scales;
105 let mut features = Vec::with_capacity(total_features);
106
107 let total_dt = (trajectory[n - 1].0 - trajectory[0].0).max(1) as f64;
109 for d in 0..dim {
110 let total_displacement = trajectory[n - 1].1[d] as f64 - trajectory[0].1[d] as f64;
111 features.push((total_displacement / total_dt) as f32);
112 }
113
114 let drift: f32 = (0..dim)
116 .map(|d| {
117 let diff = trajectory[n - 1].1[d] - trajectory[0].1[d];
118 diff * diff
119 })
120 .sum::<f32>()
121 .sqrt();
122 features.push(drift);
123
124 if n > 2 {
126 let mut vol = vec![0.0f64; dim];
127 let mut means = vec![0.0f64; dim];
128
129 let returns: Vec<Vec<f64>> = trajectory
131 .windows(2)
132 .map(|w| {
133 let dt = (w[1].0 - w[0].0).max(1) as f64;
134 (0..dim)
135 .map(|d| (w[1].1[d] as f64 - w[0].1[d] as f64) / dt)
136 .collect()
137 })
138 .collect();
139
140 for ret in &returns {
141 for d in 0..dim {
142 means[d] += ret[d];
143 }
144 }
145 let nr = returns.len() as f64;
146 for m in &mut means {
147 *m /= nr;
148 }
149
150 for ret in &returns {
151 for d in 0..dim {
152 let diff = ret[d] - means[d];
153 vol[d] += diff * diff;
154 }
155 }
156
157 for v in &vol {
158 features.push((v / (nr - 1.0).max(1.0)).sqrt() as f32);
159 }
160 } else {
161 features.extend(vec![0.0f32; dim]);
162 }
163
164 let cp_count = soft_change_point_count(
166 trajectory,
167 self.config.cp_threshold,
168 self.config.cp_smoothing,
169 );
170 features.push(cp_count);
171
172 for &scale in &self.config.scales {
174 let scale_drift = compute_scale_drift(trajectory, scale);
175 features.push(scale_drift);
176 }
177
178 Ok(features)
179 }
180
181 fn feature_dim(&self, input_dim: usize) -> usize {
182 2 * input_dim + 2 + self.config.scales.len()
183 }
184
185 fn name(&self) -> &str {
186 "analytic"
187 }
188}
189
190fn soft_change_point_count(trajectory: &[(i64, &[f32])], threshold: f32, smoothing: usize) -> f32 {
193 if trajectory.len() < 3 {
194 return 0.0;
195 }
196
197 let dim = trajectory[0].1.len();
198 let displacements: Vec<f32> = trajectory
200 .windows(2)
201 .map(|w| {
202 (0..dim)
203 .map(|d| {
204 let diff = w[1].1[d] - w[0].1[d];
205 diff * diff
206 })
207 .sum::<f32>()
208 .sqrt()
209 })
210 .collect();
211
212 let mean: f32 = displacements.iter().sum::<f32>() / displacements.len() as f32;
214 let var: f32 = displacements
215 .iter()
216 .map(|d| (d - mean) * (d - mean))
217 .sum::<f32>()
218 / displacements.len() as f32;
219 let std = var.sqrt().max(1e-10);
220
221 let mut count = 0.0f32;
223 let mut cooldown = 0usize;
224
225 for &d in &displacements {
226 if cooldown > 0 {
227 cooldown -= 1;
228 continue;
229 }
230 let z = (d - mean) / std;
231 if z > threshold {
232 count += 1.0;
233 cooldown = smoothing;
234 }
235 }
236
237 count
238}
239
240fn compute_scale_drift(trajectory: &[(i64, &[f32])], bucket_width: i64) -> f32 {
242 if trajectory.len() < 2 || bucket_width <= 0 {
243 return 0.0;
244 }
245
246 let dim = trajectory[0].1.len();
247 let t_min = trajectory[0].0;
248 let t_max = trajectory.last().unwrap().0;
249
250 let mut total_drift = 0.0f32;
251 let mut n_buckets = 0;
252
253 let mut bucket_start = t_min;
254 let mut prev_last: Option<&[f32]> = None;
255
256 while bucket_start <= t_max {
257 let bucket_end = bucket_start + bucket_width;
258
259 let last_in_bucket = trajectory
261 .iter()
262 .rev()
263 .find(|(t, _)| *t >= bucket_start && *t < bucket_end);
264
265 if let Some((_, v)) = last_in_bucket {
266 if let Some(prev) = prev_last {
267 let drift: f32 = (0..dim)
268 .map(|d| {
269 let diff = v[d] - prev[d];
270 diff * diff
271 })
272 .sum::<f32>()
273 .sqrt();
274 total_drift += drift;
275 n_buckets += 1;
276 }
277 prev_last = Some(v);
278 }
279
280 bucket_start = bucket_end;
281 }
282
283 if n_buckets > 0 {
284 total_drift / n_buckets as f32
285 } else {
286 0.0
287 }
288}
289
290#[cfg(test)]
291#[allow(clippy::needless_range_loop)]
292mod tests {
293 use super::*;
294
295 fn make_trajectory(points: &[(i64, Vec<f32>)]) -> Vec<(i64, &[f32])> {
296 points.iter().map(|(t, v)| (*t, v.as_slice())).collect()
297 }
298
299 #[test]
302 fn feature_dim_correct() {
303 let backend = AnalyticBackend::new();
304 assert_eq!(backend.feature_dim(3), 11);
306 }
307
308 #[test]
309 fn feature_vector_has_correct_size() {
310 let backend = AnalyticBackend::new();
311 let points: Vec<(i64, Vec<f32>)> = (0..20)
312 .map(|i| (i as i64 * 1_000_000, vec![i as f32; 4]))
313 .collect();
314 let traj = make_trajectory(&points);
315
316 let features = backend.extract_features(&traj).unwrap();
317 assert_eq!(features.len(), backend.feature_dim(4));
318 }
319
320 #[test]
321 fn feature_vector_is_fixed_size() {
322 let backend = AnalyticBackend::new();
323
324 for n in [5, 10, 50, 100] {
326 let points: Vec<(i64, Vec<f32>)> = (0..n)
327 .map(|i| (i as i64 * 1_000_000, vec![i as f32; 3]))
328 .collect();
329 let traj = make_trajectory(&points);
330 let features = backend.extract_features(&traj).unwrap();
331 assert_eq!(features.len(), backend.feature_dim(3), "n={n}");
332 }
333 }
334
335 #[test]
336 fn stationary_has_zero_velocity_and_drift() {
337 let backend = AnalyticBackend::new();
338 let points: Vec<(i64, Vec<f32>)> = (0..50)
339 .map(|i| (i as i64 * 1_000_000, vec![1.0, 2.0, 3.0]))
340 .collect();
341 let traj = make_trajectory(&points);
342
343 let features = backend.extract_features(&traj).unwrap();
344
345 for d in 0..3 {
347 assert!(features[d].abs() < 1e-6, "velocity[{d}] = {}", features[d]);
348 }
349 assert!(features[3].abs() < 1e-6, "drift = {}", features[3]);
351 }
352
353 #[test]
354 fn linear_trend_has_constant_velocity() {
355 let backend = AnalyticBackend::new();
356 let points: Vec<(i64, Vec<f32>)> = (0..50)
357 .map(|i| (i as i64 * 1_000_000, vec![i as f32 * 2.0, i as f32]))
358 .collect();
359 let traj = make_trajectory(&points);
360
361 let features = backend.extract_features(&traj).unwrap();
362
363 assert!(
365 (features[0] - 2e-6).abs() < 1e-8,
366 "vel[0] = {}",
367 features[0]
368 );
369 assert!(
370 (features[1] - 1e-6).abs() < 1e-8,
371 "vel[1] = {}",
372 features[1]
373 );
374
375 assert!(features[2] > 0.0);
377 }
378
379 #[test]
380 fn volatile_trajectory_has_high_volatility() {
381 let backend = AnalyticBackend::new();
382
383 let points: Vec<(i64, Vec<f32>)> = (0..50)
385 .map(|i| {
386 let v = if i % 2 == 0 { 10.0 } else { -10.0 };
387 (i as i64 * 1_000_000, vec![v])
388 })
389 .collect();
390 let traj = make_trajectory(&points);
391
392 let features = backend.extract_features(&traj).unwrap();
393 let vol = features[2];
395 assert!(
396 vol > 0.0,
397 "volatile trajectory should have positive volatility"
398 );
399 }
400
401 #[test]
402 fn soft_cp_detects_regime_change() {
403 let backend = AnalyticBackend::with_config(AnalyticConfig {
404 cp_threshold: 2.0,
405 cp_smoothing: 3,
406 ..Default::default()
407 });
408
409 let mut points: Vec<(i64, Vec<f32>)> = Vec::new();
410 for i in 0..50 {
411 points.push((i as i64 * 1_000_000, vec![0.0]));
412 }
413 for i in 50..100 {
414 points.push((i as i64 * 1_000_000, vec![10.0]));
415 }
416 let traj = make_trajectory(&points);
417
418 let features = backend.extract_features(&traj).unwrap();
419 let cp_count = features[3];
421 assert!(
422 cp_count >= 1.0,
423 "should detect at least 1 change point, got {cp_count}"
424 );
425 }
426
427 #[test]
428 fn insufficient_data() {
429 let backend = AnalyticBackend::new();
430 let points = vec![(0i64, vec![1.0f32])];
431 let traj = make_trajectory(&points);
432 assert!(backend.extract_features(&traj).is_err());
433 }
434
435 #[test]
436 fn backend_name() {
437 let backend = AnalyticBackend::new();
438 assert_eq!(backend.name(), "analytic");
439 }
440
441 #[test]
444 fn multiscale_drift_stationary_is_zero() {
445 let points: Vec<(i64, Vec<f32>)> = (0..100)
446 .map(|i| (i as i64 * 1_000_000, vec![1.0, 2.0]))
447 .collect();
448 let traj = make_trajectory(&points);
449
450 let drift = compute_scale_drift(&traj, 10_000_000);
451 assert!(drift < 1e-6, "stationary drift = {drift}");
452 }
453
454 #[test]
455 fn multiscale_drift_detects_movement() {
456 let points: Vec<(i64, Vec<f32>)> = (0..100)
457 .map(|i| (i as i64 * 1_000_000, vec![i as f32]))
458 .collect();
459 let traj = make_trajectory(&points);
460
461 let drift = compute_scale_drift(&traj, 10_000_000);
462 assert!(drift > 0.0, "moving trajectory should have positive drift");
463 }
464}