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}