cvx_analytics/
temporal_ml.rs

1//! Differentiable temporal feature extraction.
2//!
3//! Defines the `TemporalOps` trait for computing fixed-size feature vectors
4//! from variable-length embedding trajectories. The `AnalyticBackend`
5//! implementation uses closed-form computations (no ML framework needed).
6//!
7//! Future backends (BurnBackend, TorchBackend) will implement the same
8//! trait with learnable parameters for end-to-end training.
9//!
10//! # Feature Vector Layout
11//!
12//! The output is a fixed-size feature vector regardless of trajectory length:
13//!
14//! | Section | Size | Description |
15//! |---------|------|-------------|
16//! | Mean velocity | D | Average velocity per dimension |
17//! | Drift magnitude | 1 | L2 drift from first to last |
18//! | Volatility | D | Per-dimension realized volatility |
19//! | Soft change point count | 1 | Smoothed number of regime changes |
20//! | Multi-scale drift | S | Drift at S temporal scales |
21//!
22//! Total: `2*D + 2 + S` features.
23
24use cvx_core::error::AnalyticsError;
25
26/// Temporal feature extraction operations.
27///
28/// All implementations must produce the same feature vector layout
29/// for the same input, differing only in differentiability.
30pub trait TemporalOps {
31    /// Extract a fixed-size feature vector from a trajectory.
32    ///
33    /// Returns a vector of size `feature_dim()`.
34    fn extract_features(&self, trajectory: &[(i64, &[f32])]) -> Result<Vec<f32>, AnalyticsError>;
35
36    /// Output feature dimensionality.
37    fn feature_dim(&self, input_dim: usize) -> usize;
38
39    /// Backend name.
40    fn name(&self) -> &str;
41}
42
43/// Configuration for the analytic backend.
44#[derive(Debug, Clone)]
45pub struct AnalyticConfig {
46    /// Temporal scales (bucket widths in microseconds) for multi-scale drift.
47    pub scales: Vec<i64>,
48    /// Threshold for soft change point detection (z-score).
49    pub cp_threshold: f32,
50    /// Smoothing window for soft change point count.
51    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], // 1s, 10s, 100s
58            cp_threshold: 3.0,
59            cp_smoothing: 5,
60        }
61    }
62}
63
64/// Pure-Rust analytic feature extractor (non-differentiable).
65///
66/// Computes all temporal features using closed-form expressions.
67/// This is the baseline and fallback when no ML backend is available.
68pub struct AnalyticBackend {
69    config: AnalyticConfig,
70}
71
72impl AnalyticBackend {
73    /// Create with default config.
74    pub fn new() -> Self {
75        Self {
76            config: AnalyticConfig::default(),
77        }
78    }
79
80    /// Create with custom config.
81    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        // 1. Mean velocity (D features)
108        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        // 2. Drift magnitude (1 feature)
115        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        // 3. Volatility (D features)
125        if n > 2 {
126            let mut vol = vec![0.0f64; dim];
127            let mut means = vec![0.0f64; dim];
128
129            // Compute returns
130            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        // 4. Soft change point count (1 feature)
165        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        // 5. Multi-scale drift (S features)
173        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
190/// Soft change point count: number of points where the local deviation
191/// exceeds `threshold` standard deviations, smoothed by a running window.
192fn 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    // Compute per-step L2 displacement
199    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    // Running mean and std of displacements
213    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    // Count z-score exceedances with smoothing
222    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
240/// Average drift magnitude at a given temporal scale.
241fn 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        // Find last point in bucket
260        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    // ─── Feature extraction ─────────────────────────────────────────
300
301    #[test]
302    fn feature_dim_correct() {
303        let backend = AnalyticBackend::new();
304        // D=3, S=3 → 2*3 + 2 + 3 = 11
305        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        // Different trajectory lengths → same feature size
325        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        // Mean velocity (first 3 features) should be ~0
346        for d in 0..3 {
347            assert!(features[d].abs() < 1e-6, "velocity[{d}] = {}", features[d]);
348        }
349        // Drift magnitude (feature 3) should be ~0
350        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        // Mean velocity should be [2/1e6, 1/1e6]
364        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        // Drift should be > 0
376        assert!(features[2] > 0.0);
377    }
378
379    #[test]
380    fn volatile_trajectory_has_high_volatility() {
381        let backend = AnalyticBackend::new();
382
383        // Alternating values → high volatility
384        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        // Volatility is at index D+1 = 2 (after vel[0] and drift)
394        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        // Soft CP count is at index 2*D + 1 = 3
420        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    // ─── Multi-scale drift ──────────────────────────────────────────
442
443    #[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}