Skip to main content

geographdb_core/algorithms/
delay_embed.rs

1//! Takens Delay Embedding and Correlation Dimension estimator.
2//!
3//! Takens' theorem (1981): if a time series is generated by a deterministic
4//! dynamical system with attractor dimension `d`, then `2d+1` delay-embedded
5//! observations faithfully reconstruct the attractor — regardless of total
6//! sequence length.
7//!
8//! Applied to KV caches: the minimum number of KV vectors needed for lossless
9//! attention reconstruction is bounded by `2d+1`, where `d` is the correlation
10//! dimension of the KV sequence treated as a dynamical trajectory.
11//!
12//! # Workflow
13//! 1. Collect a KV sequence as rows of a matrix.
14//! 2. Embed with `delay_embedding(series, tau, embed_dim)`.
15//! 3. Estimate `d` with `correlation_dimension(points, ...)`.
16//! 4. Compute minimum cache size: `min_cache_size_takens(d)`.
17
18// ── Delay embedding ───────────────────────────────────────────────────────────
19
20/// Build a delay-embedded point cloud from a scalar or vector time series.
21///
22/// Each input row `series[t]` is a feature vector of length `n_features`.
23/// The output row `t` is the concatenation of `series[t], series[t-tau],
24/// series[t-2*tau], ..., series[t-(embed_dim-1)*tau]`, zero-padded for
25/// indices before the start of the series.
26///
27/// Returns a matrix of shape `[series.len(), n_features * embed_dim]`.
28pub fn delay_embedding(series: &[Vec<f32>], tau: usize, embed_dim: usize) -> Vec<Vec<f32>> {
29    let n = series.len();
30    if n == 0 || embed_dim == 0 {
31        return Vec::new();
32    }
33    let n_features = series[0].len();
34    let out_dim = n_features * embed_dim;
35
36    (0..n)
37        .map(|t| {
38            let mut row = vec![0.0f32; out_dim];
39            for lag in 0..embed_dim {
40                let src_t = (t as isize) - (lag * tau) as isize;
41                if src_t >= 0 {
42                    let src = &series[src_t as usize];
43                    let offset = lag * n_features;
44                    row[offset..offset + n_features].copy_from_slice(src);
45                }
46                // else: stays zero (zero-pad)
47            }
48            row
49        })
50        .collect()
51}
52
53// ── Correlation dimension ─────────────────────────────────────────────────────
54
55/// Estimate the correlation dimension of a point cloud using the
56/// Grassberger–Procaccia algorithm (1983).
57///
58/// Sweeps radius `r` from `r_min` to `r_max` (log-uniform, `n_steps` steps),
59/// computes `C(r) = fraction of pairs with distance < r`, fits `log C(r) vs
60/// log r` by least-squares, and returns the slope (= correlation dimension).
61///
62/// Uses Euclidean distance. For large point clouds, samples up to
63/// `max_pairs` random pairs to keep cost manageable.
64pub fn correlation_dimension(points: &[Vec<f32>], r_min: f32, r_max: f32, n_steps: usize) -> f64 {
65    let n = points.len();
66    if n < 4 || r_min <= 0.0 || r_max <= r_min || n_steps < 2 {
67        return 0.0;
68    }
69
70    // Build log-uniform radius grid
71    let log_rmin = (r_min as f64).ln();
72    let log_rmax = (r_max as f64).ln();
73    let radii: Vec<f64> = (0..n_steps)
74        .map(|i| {
75            let t = i as f64 / (n_steps - 1) as f64;
76            (log_rmin + t * (log_rmax - log_rmin)).exp()
77        })
78        .collect();
79
80    // Count pairs within each radius (O(n²) — acceptable for small n)
81    let total_pairs = (n * (n - 1) / 2) as f64;
82    let mut log_r_fit: Vec<f64> = Vec::with_capacity(n_steps);
83    let mut log_c_fit: Vec<f64> = Vec::with_capacity(n_steps);
84
85    for &r in &radii {
86        let mut count = 0usize;
87        for i in 0..n {
88            for j in (i + 1)..n {
89                if euclidean_dist_sq(&points[i], &points[j]) < (r * r) as f32 {
90                    count += 1;
91                }
92            }
93        }
94        // Skip radii where fewer than 2 pairs fall inside — log(ε) at those
95        // points is dominated by the floor constant and distorts the slope.
96        if count < 2 {
97            continue;
98        }
99        let c = count as f64 / total_pairs;
100        log_r_fit.push(r.ln());
101        log_c_fit.push(c.ln());
102    }
103
104    // Need at least two valid points for a slope
105    let m = log_r_fit.len();
106    if m < 2 {
107        return 0.0;
108    }
109
110    // Least-squares slope: d = Σ(log_r * log_c) / Σ(log_r²) (mean-centred)
111    let mean_r = log_r_fit.iter().sum::<f64>() / m as f64;
112    let mean_c = log_c_fit.iter().sum::<f64>() / m as f64;
113    let num: f64 = log_r_fit
114        .iter()
115        .zip(log_c_fit.iter())
116        .map(|(r, c)| (r - mean_r) * (c - mean_c))
117        .sum();
118    let den: f64 = log_r_fit.iter().map(|r| (r - mean_r).powi(2)).sum();
119    if den.abs() < 1e-15 {
120        return 0.0;
121    }
122    (num / den).max(0.0)
123}
124
125/// Minimum number of KV vectors needed to reconstruct the attractor, per
126/// Takens' theorem: `2 * ceil(d) + 1`.
127pub fn min_cache_size_takens(dim_estimate: f64) -> usize {
128    2 * dim_estimate.ceil() as usize + 1
129}
130
131// ── Helpers ───────────────────────────────────────────────────────────────────
132
133fn euclidean_dist_sq(a: &[f32], b: &[f32]) -> f32 {
134    a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
135}
136
137// ── Tests ─────────────────────────────────────────────────────────────────────
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    // ── delay_embedding ───────────────────────────────────────────────────────
144
145    #[test]
146    fn test_delay_embedding_shape() {
147        let series: Vec<Vec<f32>> = (0..10).map(|i| vec![i as f32, i as f32 + 1.0]).collect();
148        let embedded = delay_embedding(&series, 1, 3);
149        assert_eq!(embedded.len(), 10);
150        assert_eq!(embedded[0].len(), 6); // 2 features × 3 lags
151    }
152
153    #[test]
154    fn test_delay_embedding_tau1_lag0_matches_original() {
155        let series: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32]).collect();
156        let embedded = delay_embedding(&series, 1, 2);
157        // First lag should always equal the current time step
158        for (t, row) in embedded.iter().enumerate() {
159            assert_eq!(row[0], t as f32, "lag-0 mismatch at t={t}");
160        }
161    }
162
163    #[test]
164    fn test_delay_embedding_zero_pads_before_start() {
165        let series: Vec<Vec<f32>> = (0..4).map(|i| vec![i as f32]).collect();
166        let embedded = delay_embedding(&series, 1, 3);
167        // t=0: lags [t=0, t=-1, t=-2] → [0.0, 0.0, 0.0]
168        assert_eq!(embedded[0], vec![0.0, 0.0, 0.0]);
169        // t=1: lags [t=1, t=0, t=-1] → [1.0, 0.0, 0.0]
170        assert_eq!(embedded[1], vec![1.0, 0.0, 0.0]);
171        // t=2: lags [t=2, t=1, t=0] → [2.0, 1.0, 0.0]
172        assert_eq!(embedded[2], vec![2.0, 1.0, 0.0]);
173    }
174
175    #[test]
176    fn test_delay_embedding_empty_series() {
177        let embedded = delay_embedding(&[], 1, 3);
178        assert!(embedded.is_empty());
179    }
180
181    // ── correlation_dimension ─────────────────────────────────────────────────
182
183    /// A 1D line → correlation dimension ≈ 1.0
184    #[test]
185    fn test_correlation_dim_line() {
186        // 50 evenly-spaced points on [0, 1]
187        let points: Vec<Vec<f32>> = (0..50).map(|i| vec![i as f32 / 49.0]).collect();
188        let d = correlation_dimension(&points, 0.01, 1.0, 20);
189        assert!(
190            (d - 1.0).abs() < 0.3,
191            "line should have corr dim ≈ 1.0, got {d:.3}"
192        );
193    }
194
195    /// Points on a unit circle → correlation dimension ≈ 1.0 (1D manifold)
196    #[test]
197    fn test_correlation_dim_circle() {
198        let points: Vec<Vec<f32>> = (0..60)
199            .map(|i| {
200                let theta = 2.0 * std::f32::consts::PI * i as f32 / 60.0;
201                vec![theta.cos(), theta.sin()]
202            })
203            .collect();
204        let d = correlation_dimension(&points, 0.05, 1.5, 20);
205        assert!(
206            (d - 1.0).abs() < 0.4,
207            "circle should have corr dim ≈ 1.0, got {d:.3}"
208        );
209    }
210
211    /// Points on a 2D surface → correlation dimension ≈ 2.0
212    #[test]
213    fn test_correlation_dim_plane() {
214        // 100 points on [0,1]² grid
215        let points: Vec<Vec<f32>> = (0..10)
216            .flat_map(|i| (0..10).map(move |j| vec![i as f32 / 9.0, j as f32 / 9.0]))
217            .collect();
218        let d = correlation_dimension(&points, 0.05, 1.0, 20);
219        assert!(
220            (d - 2.0).abs() < 0.5,
221            "plane should have corr dim ≈ 2.0, got {d:.3}"
222        );
223    }
224
225    /// Too few points → returns 0.0 without panic
226    #[test]
227    fn test_correlation_dim_degenerate() {
228        let d = correlation_dimension(&[vec![1.0f32], vec![2.0f32]], 0.1, 1.0, 10);
229        assert_eq!(d, 0.0);
230    }
231
232    // ── min_cache_size_takens ─────────────────────────────────────────────────
233
234    #[test]
235    fn test_min_cache_size_formula() {
236        assert_eq!(min_cache_size_takens(1.0), 3); // 2*1+1
237        assert_eq!(min_cache_size_takens(2.0), 5); // 2*2+1
238        assert_eq!(min_cache_size_takens(64.0), 129); // 2*64+1
239        assert_eq!(min_cache_size_takens(1.5), 5); // 2*ceil(1.5)+1 = 2*2+1
240    }
241
242    #[test]
243    fn test_min_cache_size_fractional_rounds_up() {
244        // d=0.7 → ceil=1 → 2*1+1 = 3
245        assert_eq!(min_cache_size_takens(0.7), 3);
246        // d=2.1 → ceil=3 → 2*3+1 = 7
247        assert_eq!(min_cache_size_takens(2.1), 7);
248    }
249}