Skip to main content

ftui_runtime/
rough_path.rs

1//! Rough-path signatures for sequential trace feature extraction.
2//!
3//! Rough-path signatures are universal noncommutative features extracted from
4//! sequential data (frame time series, event streams, syscall traces). They
5//! capture the "shape" of a path regardless of parameterization, making them
6//! ideal for anomaly detection and workload characterization.
7//!
8//! # Mathematical Model
9//!
10//! Given a d-dimensional path X: [0,T] → ℝ^d, the **signature** is the
11//! collection of iterated integrals:
12//!
13//! ```text
14//! S(X)^{i₁,...,iₖ} = ∫₀<t₁<...<tₖ<T dX^{i₁}_{t₁} ⊗ ... ⊗ dX^{iₖ}_{tₖ}
15//! ```
16//!
17//! The **truncated signature** at depth N keeps only terms with k ≤ N.
18//! For practical use, depth 3-4 captures sufficient structure.
19//!
20//! # Discretization
21//!
22//! For discrete time series X = (x₀, x₁, ..., x_n), the increments are
23//! Δx_i = x_{i+1} - x_i, and the iterated integrals become iterated sums:
24//!
25//! ```text
26//! S^{i}     = Σ Δx^i_k                      (depth 1)
27//! S^{i,j}   = Σ_{k<l} Δx^i_k · Δx^j_l      (depth 2)
28//! S^{i,j,m} = Σ_{k<l<r} Δx^i_k · Δx^j_l · Δx^m_r  (depth 3)
29//! ```
30//!
31//! # Efficient Computation (Chen's Identity)
32//!
33//! Rather than brute-force triple loops, we use Chen's identity for
34//! incremental computation:
35//!
36//! ```text
37//! S(X_{[0,t+1]}) = S(X_{[0,t]}) ⊗ S(Δx_{t})
38//! ```
39//!
40//! where ⊗ is the tensor (shuffle) product. This gives O(n · d^N) instead
41//! of O(n^N · d^N).
42//!
43//! # Usage
44//!
45//! ```rust
46//! use ftui_runtime::rough_path::SignatureExtractor;
47//!
48//! // 2D path: (frame_time_ms, alloc_count)
49//! let mut ext = SignatureExtractor::new(2, 3); // dim=2, depth=3
50//!
51//! ext.observe(&[16.0, 1200.0]);
52//! ext.observe(&[17.0, 1250.0]);
53//! ext.observe(&[15.0, 1180.0]);
54//! ext.observe(&[32.0, 1500.0]); // spike!
55//!
56//! let sig = ext.signature();
57//! // sig contains depth-1, depth-2, and depth-3 terms
58//! // Use for anomaly detection, distance computation, etc.
59//! ```
60//!
61//! # Applications
62//!
63//! | Use case | Dimensions | What it detects |
64//! |----------|-----------|-----------------|
65//! | Frame timing | 1D (ms) | Stutter patterns, periodicity |
66//! | Render+alloc | 2D (ms, bytes) | Correlated regressions |
67//! | Event stream | 3D (dt, type, payload) | Workload fingerprints |
68//!
69//! # Fallback
70//!
71//! When signatures are too expensive (very high-dimensional data), use
72//! standard statistical features (mean, variance, skew, autocorrelation).
73
74/// Configuration for signature extraction.
75#[derive(Debug, Clone)]
76pub struct SignatureConfig {
77    /// Number of dimensions in the input path.
78    pub dim: usize,
79    /// Maximum depth of the truncated signature (typically 2-4).
80    pub max_depth: usize,
81}
82
83/// Computes the number of signature components for given dim and depth.
84///
85/// Total terms = Σ_{k=1}^{depth} dim^k = dim * (dim^depth - 1) / (dim - 1)
86/// For dim=1, it's just `depth`.
87pub fn signature_size(dim: usize, max_depth: usize) -> usize {
88    if dim == 0 || max_depth == 0 {
89        return 0;
90    }
91    if dim == 1 {
92        return max_depth;
93    }
94    let mut total: usize = 0;
95    let mut power: usize = 1;
96    for _ in 0..max_depth {
97        power = power.saturating_mul(dim);
98        total = total.saturating_add(power);
99    }
100    total
101}
102
103/// Incremental truncated signature extractor.
104///
105/// Maintains the running signature of a d-dimensional discrete path,
106/// updated incrementally via Chen's identity.
107#[derive(Debug, Clone)]
108pub struct SignatureExtractor {
109    dim: usize,
110    max_depth: usize,
111    /// Flattened signature terms, organized by depth.
112    /// Depth k has dim^k terms, stored in row-major order of multi-indices.
113    terms: Vec<f64>,
114    /// Offsets into `terms` for each depth level.
115    offsets: Vec<usize>,
116    /// Number of observations seen.
117    count: usize,
118    /// Last observed point (for computing increments).
119    last: Option<Vec<f64>>,
120}
121
122impl SignatureExtractor {
123    /// Create a new extractor for `dim`-dimensional paths at truncation `max_depth`.
124    pub fn new(dim: usize, max_depth: usize) -> Self {
125        let total = signature_size(dim, max_depth);
126        let mut offsets = Vec::with_capacity(max_depth + 1);
127        offsets.push(0);
128        let mut power = 1;
129        for _ in 0..max_depth {
130            power *= dim;
131            offsets.push(offsets.last().unwrap() + power);
132        }
133        Self {
134            dim,
135            max_depth,
136            terms: vec![0.0; total],
137            offsets,
138            count: 0,
139            last: None,
140        }
141    }
142
143    /// Number of dimensions.
144    pub fn dim(&self) -> usize {
145        self.dim
146    }
147
148    /// Maximum signature depth.
149    pub fn max_depth(&self) -> usize {
150        self.max_depth
151    }
152
153    /// Number of observations processed.
154    pub fn count(&self) -> usize {
155        self.count
156    }
157
158    /// Total number of signature terms.
159    pub fn num_terms(&self) -> usize {
160        self.terms.len()
161    }
162
163    /// Observe a new point on the path.
164    ///
165    /// `point` must have length `dim`. Panics if the length is wrong.
166    /// The first observation establishes the baseline; increments start
167    /// from the second observation onward.
168    pub fn observe(&mut self, point: &[f64]) {
169        assert_eq!(
170            point.len(),
171            self.dim,
172            "point dimension {} != extractor dimension {}",
173            point.len(),
174            self.dim
175        );
176
177        self.count += 1;
178
179        if let Some(ref last) = self.last {
180            // Compute increment.
181            let dx: Vec<f64> = point.iter().zip(last.iter()).map(|(a, b)| a - b).collect();
182            // Update signature via Chen's identity (incremental).
183            self.extend_signature(&dx);
184        }
185
186        self.last = Some(point.to_vec());
187    }
188
189    /// Extend the running signature by one increment using Chen's identity.
190    ///
191    /// For each depth k from `max_depth` down to 1:
192    ///   S^{i₁,...,iₖ} += S^{i₁,...,i_{k-1}} · Δx^{iₖ}
193    ///
194    /// Processing from highest depth down prevents double-counting.
195    fn extend_signature(&mut self, dx: &[f64]) {
196        // Process depths from highest to lowest to avoid using updated lower
197        // depths in the same step.
198        for depth in (1..=self.max_depth).rev() {
199            if depth == 1 {
200                // Depth 1: S^i += dx^i
201                for (term, &dx_val) in self.terms[..self.dim].iter_mut().zip(dx.iter()) {
202                    *term += dx_val;
203                }
204            } else {
205                // Depth k > 1: S^{...,i} += S^{...} * dx^i
206                // Parent has dim^(k-1) terms at offset[k-2]..offset[k-1]
207                let parent_start = self.offsets[depth - 2];
208                let parent_count = self.offsets[depth - 1] - parent_start;
209                let child_start = self.offsets[depth - 1];
210
211                // Read parent values first (snapshot to avoid borrow issues).
212                let parents: Vec<f64> =
213                    self.terms[parent_start..parent_start + parent_count].to_vec();
214
215                for (p_idx, &parent_val) in parents.iter().enumerate() {
216                    for (d_idx, &dx_val) in dx.iter().enumerate() {
217                        let child_idx = child_start + p_idx * self.dim + d_idx;
218                        self.terms[child_idx] += parent_val * dx_val;
219                    }
220                }
221            }
222        }
223    }
224
225    /// Get the current signature as a slice.
226    pub fn signature(&self) -> &[f64] {
227        &self.terms
228    }
229
230    /// Get signature terms at a specific depth (1-indexed).
231    ///
232    /// Returns `None` if depth is 0 or exceeds `max_depth`.
233    pub fn signature_at_depth(&self, depth: usize) -> Option<&[f64]> {
234        if depth == 0 || depth > self.max_depth {
235            return None;
236        }
237        let start = self.offsets[depth - 1];
238        let end = self.offsets[depth];
239        Some(&self.terms[start..end])
240    }
241
242    /// L2 norm of the signature vector.
243    pub fn norm(&self) -> f64 {
244        self.terms.iter().map(|x| x * x).sum::<f64>().sqrt()
245    }
246
247    /// Reset the extractor to its initial state.
248    pub fn reset(&mut self) {
249        self.terms.fill(0.0);
250        self.count = 0;
251        self.last = None;
252    }
253}
254
255/// Compute the L2 distance between two signature vectors.
256///
257/// The signatures must have the same length.
258pub fn signature_distance(a: &[f64], b: &[f64]) -> f64 {
259    assert_eq!(a.len(), b.len(), "signature length mismatch");
260    a.iter()
261        .zip(b.iter())
262        .map(|(x, y)| (x - y) * (x - y))
263        .sum::<f64>()
264        .sqrt()
265}
266
267// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn signature_size_basic() {
277        assert_eq!(signature_size(0, 3), 0);
278        assert_eq!(signature_size(3, 0), 0);
279        assert_eq!(signature_size(1, 3), 3); // 1 + 1 + 1
280        assert_eq!(signature_size(2, 1), 2); // 2
281        assert_eq!(signature_size(2, 2), 6); // 2 + 4
282        assert_eq!(signature_size(2, 3), 14); // 2 + 4 + 8
283        assert_eq!(signature_size(3, 2), 12); // 3 + 9
284    }
285
286    #[test]
287    fn extractor_initial_state() {
288        let ext = SignatureExtractor::new(2, 3);
289        assert_eq!(ext.dim(), 2);
290        assert_eq!(ext.max_depth(), 3);
291        assert_eq!(ext.count(), 0);
292        assert_eq!(ext.num_terms(), 14); // 2 + 4 + 8
293        assert!(ext.signature().iter().all(|&x| x == 0.0));
294    }
295
296    #[test]
297    fn single_observation_no_signature() {
298        let mut ext = SignatureExtractor::new(2, 2);
299        ext.observe(&[1.0, 2.0]);
300        // Only one point — no increment, signature stays zero.
301        assert!(ext.signature().iter().all(|&x| x == 0.0));
302        assert_eq!(ext.count(), 1);
303    }
304
305    #[test]
306    fn depth1_is_total_increment() {
307        let mut ext = SignatureExtractor::new(2, 2);
308        ext.observe(&[0.0, 0.0]);
309        ext.observe(&[1.0, 3.0]);
310        ext.observe(&[4.0, 5.0]);
311
312        // Depth 1: total increments = (1,3) + (3,2) = (4, 5)
313        let d1 = ext.signature_at_depth(1).unwrap();
314        assert!((d1[0] - 4.0).abs() < 1e-10);
315        assert!((d1[1] - 5.0).abs() < 1e-10);
316    }
317
318    #[test]
319    fn depth2_iterated_integrals() {
320        let mut ext = SignatureExtractor::new(1, 2);
321        // 1D path: 0 → 1 → 3 → 6
322        // Increments: 1, 2, 3
323        ext.observe(&[0.0]);
324        ext.observe(&[1.0]);
325        ext.observe(&[3.0]);
326        ext.observe(&[6.0]);
327
328        // Depth 1: S^1 = 1 + 2 + 3 = 6
329        let d1 = ext.signature_at_depth(1).unwrap();
330        assert!((d1[0] - 6.0).abs() < 1e-10);
331
332        // Depth 2: S^{1,1} = Σ_{k<l} Δx_k · Δx_l
333        // = 1*2 + 1*3 + 2*3 = 2 + 3 + 6 = 11
334        let d2 = ext.signature_at_depth(2).unwrap();
335        assert!((d2[0] - 11.0).abs() < 1e-10);
336    }
337
338    #[test]
339    fn signature_depth_out_of_range() {
340        let ext = SignatureExtractor::new(2, 3);
341        assert!(ext.signature_at_depth(0).is_none());
342        assert!(ext.signature_at_depth(4).is_none());
343        assert!(ext.signature_at_depth(1).is_some());
344        assert!(ext.signature_at_depth(3).is_some());
345    }
346
347    #[test]
348    fn norm_computation() {
349        let mut ext = SignatureExtractor::new(1, 1);
350        ext.observe(&[0.0]);
351        ext.observe(&[3.0]);
352        // Depth 1: S^1 = 3
353        assert!((ext.norm() - 3.0).abs() < 1e-10);
354    }
355
356    #[test]
357    fn distance_computation() {
358        let a = vec![1.0, 2.0, 3.0];
359        let b = vec![4.0, 6.0, 3.0];
360        // distance = sqrt(9 + 16 + 0) = 5
361        assert!((signature_distance(&a, &b) - 5.0).abs() < 1e-10);
362    }
363
364    #[test]
365    fn reset() {
366        let mut ext = SignatureExtractor::new(2, 2);
367        ext.observe(&[0.0, 0.0]);
368        ext.observe(&[1.0, 1.0]);
369        assert!(ext.norm() > 0.0);
370        ext.reset();
371        assert_eq!(ext.count(), 0);
372        assert!(ext.signature().iter().all(|&x| x == 0.0));
373    }
374
375    #[test]
376    fn two_dim_depth3() {
377        let mut ext = SignatureExtractor::new(2, 3);
378        ext.observe(&[0.0, 0.0]);
379        ext.observe(&[1.0, 0.0]);
380        ext.observe(&[1.0, 1.0]);
381
382        // Increments: (1,0), (0,1)
383        // Depth 1: (1, 1)
384        let d1 = ext.signature_at_depth(1).unwrap();
385        assert!((d1[0] - 1.0).abs() < 1e-10);
386        assert!((d1[1] - 1.0).abs() < 1e-10);
387
388        // Depth 2 (2x2 = 4 terms): S^{ij} = Σ_{k<l} Δx^i_k · Δx^j_l
389        // S^{1,1} = 1*0 = 0
390        // S^{1,2} = 1*1 = 1
391        // S^{2,1} = 0*0 = 0
392        // S^{2,2} = 0*1 = 0
393        let d2 = ext.signature_at_depth(2).unwrap();
394        assert!((d2[0] - 0.0).abs() < 1e-10); // S^{1,1}
395        assert!((d2[1] - 1.0).abs() < 1e-10); // S^{1,2}
396        assert!((d2[2] - 0.0).abs() < 1e-10); // S^{2,1}
397        assert!((d2[3] - 0.0).abs() < 1e-10); // S^{2,2}
398    }
399
400    #[test]
401    fn translation_invariance() {
402        // Signature should be the same regardless of starting point.
403        let mut ext1 = SignatureExtractor::new(2, 2);
404        ext1.observe(&[0.0, 0.0]);
405        ext1.observe(&[1.0, 2.0]);
406        ext1.observe(&[3.0, 1.0]);
407
408        let mut ext2 = SignatureExtractor::new(2, 2);
409        ext2.observe(&[100.0, 200.0]);
410        ext2.observe(&[101.0, 202.0]);
411        ext2.observe(&[103.0, 201.0]);
412
413        let s1 = ext1.signature();
414        let s2 = ext2.signature();
415        for (a, b) in s1.iter().zip(s2.iter()) {
416            assert!(
417                (a - b).abs() < 1e-10,
418                "Translation invariance violated: {} != {}",
419                a,
420                b
421            );
422        }
423    }
424
425    #[test]
426    fn constant_path_zero_signature() {
427        // If all points are the same, all increments are zero.
428        let mut ext = SignatureExtractor::new(3, 3);
429        for _ in 0..10 {
430            ext.observe(&[5.0, 5.0, 5.0]);
431        }
432        assert!(ext.signature().iter().all(|&x| x == 0.0));
433    }
434
435    #[test]
436    fn signature_distance_zero_for_same() {
437        let a = vec![1.0, 2.0, 3.0];
438        assert!((signature_distance(&a, &a)).abs() < 1e-15);
439    }
440
441    #[test]
442    #[should_panic(expected = "point dimension")]
443    fn wrong_dimension_panics() {
444        let mut ext = SignatureExtractor::new(2, 2);
445        ext.observe(&[1.0]); // wrong: expected 2
446    }
447
448    #[test]
449    fn frame_timing_anomaly_detection() {
450        // Simulate: normal frame times ~16ms, then anomalous spike
451        let mut normal = SignatureExtractor::new(1, 3);
452        for &t in &[16.0, 16.1, 15.9, 16.0, 16.2, 15.8, 16.0, 16.1] {
453            normal.observe(&[t]);
454        }
455
456        let mut anomalous = SignatureExtractor::new(1, 3);
457        for &t in &[16.0, 16.1, 15.9, 64.0, 16.0, 16.0, 16.0, 16.0] {
458            anomalous.observe(&[t]);
459        }
460
461        // The anomalous path should have a significantly different signature
462        let dist = signature_distance(normal.signature(), anomalous.signature());
463        assert!(
464            dist > 1.0,
465            "Anomalous path should be far from normal (dist={})",
466            dist
467        );
468    }
469}