Skip to main content

scirs2_interpolate/physics_informed/
streaming.rs

1//! Online / incremental RBF interpolation with a sliding window.
2//!
3//! The `StreamingRbf` maintains a Gram matrix in Cholesky-factored form and
4//! supports:
5//!
6//! 1. **Rank-1 Cholesky update** when a new point is appended.
7//! 2. **Rank-1 Cholesky downdate** when the oldest point is evicted once the
8//!    window size is exceeded.
9//! 3. **Forget factor** that shrinks stale information before each update.
10//!
11//! The implementation uses a multiquadric kernel  φ(r) = √(1 + (ε r)²)  and
12//! solves the system  G α = y  where  G_{ij} = φ(||x_i − x_j||).
13
14use crate::error::InterpolateError;
15
16/// Inverse multiquadric kernel: φ(r) = 1 / sqrt(1 + (ε r)²).
17///
18/// This kernel is strictly positive definite, meaning the Gram matrix G with
19/// G_{ij} = φ(||xi − xj||) is SPD for any set of distinct points.
20#[inline]
21fn inv_multiquadric(r: f64, eps: f64) -> f64 {
22    1.0 / (1.0 + (eps * r) * (eps * r)).sqrt()
23}
24
25// ─────────────────────────────────────────────────────────────────────────────
26// Configuration
27// ─────────────────────────────────────────────────────────────────────────────
28
29/// Configuration for the streaming RBF interpolant.
30#[derive(Debug, Clone)]
31pub struct StreamingRbfConfig {
32    /// Maximum number of points to keep (sliding window size).
33    pub window_size: usize,
34    /// Multiquadric shape parameter ε.
35    pub shape_param: f64,
36    /// Forget factor applied to the Gram matrix before each update (0 < γ ≤ 1).
37    /// A value < 1 down-weights older data.
38    pub forget_factor: f64,
39}
40
41impl Default for StreamingRbfConfig {
42    fn default() -> Self {
43        Self {
44            window_size: 200,
45            shape_param: 1.0,
46            forget_factor: 0.99,
47        }
48    }
49}
50
51// ─────────────────────────────────────────────────────────────────────────────
52// Streaming RBF struct
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Online / incremental RBF interpolant.
56///
57/// Points are added one at a time via [`update`](StreamingRbf::update).  When
58/// the window is full the oldest point is removed via a rank-1 downdate.
59#[derive(Debug, Clone)]
60pub struct StreamingRbf {
61    config: StreamingRbfConfig,
62    /// Sliding window of input points.
63    points: Vec<Vec<f64>>,
64    /// Corresponding function values.
65    values: Vec<f64>,
66    /// Lower-triangular Cholesky factor of the Gram matrix G.
67    /// `l` has shape `(n × n)` where `n = points.len()`.
68    l: Vec<Vec<f64>>,
69    /// RBF coefficients (solution of G α = y).
70    coeffs: Vec<f64>,
71    /// Whether the coefficient vector is up-to-date.
72    coeffs_dirty: bool,
73}
74
75impl StreamingRbf {
76    /// Create a new, empty streaming RBF.
77    pub fn new(config: StreamingRbfConfig) -> Self {
78        Self {
79            config,
80            points: Vec::new(),
81            values: Vec::new(),
82            l: Vec::new(),
83            coeffs: Vec::new(),
84            coeffs_dirty: false,
85        }
86    }
87
88    /// Number of points currently in the window.
89    pub fn n_points(&self) -> usize {
90        self.points.len()
91    }
92
93    // ── kernel helpers ────────────────────────────────────────────────────
94
95    fn dist(a: &[f64], b: &[f64]) -> f64 {
96        a.iter()
97            .zip(b.iter())
98            .map(|(&ai, &bi)| (ai - bi) * (ai - bi))
99            .sum::<f64>()
100            .sqrt()
101    }
102
103    fn phi(&self, a: &[f64], b: &[f64]) -> f64 {
104        inv_multiquadric(Self::dist(a, b), self.config.shape_param)
105    }
106
107    // ── dense Cholesky of a positive-definite n×n matrix ─────────────────
108
109    fn full_cholesky(g: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, InterpolateError> {
110        let n = g.len();
111        // Add a small ridge for numerical safety.
112        let ridge = 1e-12;
113        let mut l = vec![vec![0.0_f64; n]; n];
114        for i in 0..n {
115            for j in 0..=i {
116                let mut s = g[i][j] + if i == j { ridge } else { 0.0 };
117                for k in 0..j {
118                    s -= l[i][k] * l[j][k];
119                }
120                if i == j {
121                    if s <= 0.0 {
122                        s = ridge;
123                    }
124                    l[i][j] = s.sqrt();
125                } else {
126                    l[i][j] = s / l[j][j];
127                }
128            }
129        }
130        Ok(l)
131    }
132
133    // ── forward / back substitution ───────────────────────────────────────
134
135    fn forward_sub(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
136        let n = l.len();
137        let mut y = vec![0.0; n];
138        for i in 0..n {
139            let mut s = b[i];
140            for k in 0..i {
141                s -= l[i][k] * y[k];
142            }
143            y[i] = s / l[i][i];
144        }
145        y
146    }
147
148    fn back_sub(l: &[Vec<f64>], y: &[f64]) -> Vec<f64> {
149        let n = l.len();
150        let mut x = vec![0.0; n];
151        for i in (0..n).rev() {
152            let mut s = y[i];
153            for k in (i + 1)..n {
154                s -= l[k][i] * x[k];
155            }
156            x[i] = s / l[i][i];
157        }
158        x
159    }
160
161    fn solve_cholesky(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
162        let y = Self::forward_sub(l, b);
163        Self::back_sub(l, &y)
164    }
165
166    // ── rank-1 Cholesky update: L → L' such that L'L'ᵀ = LLᵀ + v vᵀ ─────
167
168    /// Performs an O(n²) Cholesky extension.
169    ///
170    /// Appends a new row and column to the current lower-triangular Cholesky
171    /// factor `L` so that the extended factor corresponds to the augmented
172    /// Gram matrix `G' = [[G, v[..n]]; [v[..n]ᵀ, v[n]]]`.
173    ///
174    /// `v` has length `old_n + 1`:
175    /// - `v[0..old_n]` are the cross-kernel values between the new point and
176    ///   all existing points.
177    /// - `v[old_n]` is the self-kernel value `φ(new, new)`.
178    pub fn cholesky_rank1_update(l: &mut Vec<Vec<f64>>, v: &[f64]) -> Result<(), InterpolateError> {
179        // old_n is the current (pre-extension) dimension.
180        let old_n = l.len();
181        let new_n = old_n + 1;
182
183        if v.len() != new_n {
184            return Err(InterpolateError::DimensionMismatch(format!(
185                "cholesky_rank1_update: v has length {} but expected {}",
186                v.len(),
187                new_n
188            )));
189        }
190
191        // Extend each existing row to width new_n.
192        for row in l.iter_mut() {
193            row.push(0.0);
194        }
195        // Append the new (last) row.
196        l.push(vec![0.0; new_n]);
197
198        // Compute the new last row by solving L_{old} w = v[0..old_n].
199        let w = if old_n > 0 {
200            let v_sub = &v[..old_n];
201            let l_old: Vec<Vec<f64>> = l[..old_n].iter().map(|r| r[..old_n].to_vec()).collect();
202            Self::forward_sub(&l_old, v_sub)
203        } else {
204            vec![]
205        };
206
207        // Fill in off-diagonal entries of the new last row.
208        for j in 0..old_n {
209            l[old_n][j] = w[j];
210        }
211
212        // Diagonal element: sqrt(v[old_n] - ||w||²).
213        let w_norm2: f64 = w.iter().map(|&wi| wi * wi).sum();
214        let diag2 = v[old_n] - w_norm2;
215        let diag = if diag2 <= 0.0 {
216            1e-10_f64
217        } else {
218            diag2.sqrt()
219        };
220        l[old_n][old_n] = diag;
221
222        Ok(())
223    }
224
225    // ── rank-1 Cholesky downdate: remove first row/column ─────────────────
226    // Strategy: after removing the first column/row from L (and hence from the
227    // Gram matrix), we recompute L from scratch on the reduced n-1 × n-1 system.
228    // This is O(n³) in the worst case but n ≤ window_size which is typically
229    // small enough.
230
231    #[allow(dead_code)]
232    fn remove_first_point(&mut self) -> Result<(), InterpolateError> {
233        // Remove first point
234        self.points.remove(0);
235        self.values.remove(0);
236        let n = self.points.len();
237        if n == 0 {
238            self.l.clear();
239            self.coeffs.clear();
240            self.coeffs_dirty = false;
241            return Ok(());
242        }
243        // Rebuild Gram matrix G from scratch after removal.
244        let mut g = vec![vec![0.0_f64; n]; n];
245        for i in 0..n {
246            for j in 0..n {
247                let r = Self::dist(&self.points[i], &self.points[j]);
248                g[i][j] = inv_multiquadric(r, self.config.shape_param);
249            }
250        }
251        self.l = Self::full_cholesky(&g)?;
252        self.coeffs_dirty = true;
253        Ok(())
254    }
255
256    // ── update coefficients if dirty ──────────────────────────────────────
257
258    fn refresh_coeffs(&mut self) {
259        if !self.coeffs_dirty || self.points.is_empty() {
260            return;
261        }
262        self.coeffs = Self::solve_cholesky(&self.l, &self.values);
263        self.coeffs_dirty = false;
264    }
265
266    // ─────────────────────────────────────────────────────────────────────
267    // Public API
268    // ─────────────────────────────────────────────────────────────────────
269
270    /// Add a new observation `(x, y)` and update the interpolant.
271    ///
272    /// If the window is full the oldest observation is evicted first.
273    ///
274    /// Implementation strategy:
275    /// - When `forget_factor == 1.0` (no forgetting): use rank-1 Cholesky update
276    ///   (O(n²)) by appending the new column.
277    /// - When `forget_factor < 1.0`: apply exponential forgetting to stored values
278    ///   and rebuild the Gram matrix from scratch (O(n²) rebuild, but avoids
279    ///   the numerical issues of scaling an existing factorisation).
280    pub fn update(&mut self, x: Vec<f64>, y: f64) -> Result<(), InterpolateError> {
281        let gamma = self.config.forget_factor;
282
283        // Evict oldest if window is full before adding the new point.
284        if self.points.len() >= self.config.window_size {
285            self.points.remove(0);
286            self.values.remove(0);
287        }
288
289        // Apply forget factor: discount stored values (used for weighted solve).
290        if gamma < 1.0 {
291            for v in &mut self.values {
292                *v *= gamma;
293            }
294        }
295
296        // Add new point.
297        self.points.push(x);
298        self.values.push(y);
299
300        // Rebuild Gram matrix and its Cholesky factor from scratch.
301        // O(n²) but ensures numerical correctness across all code paths.
302        let n = self.points.len();
303        let eps = self.config.shape_param;
304        let mut g = vec![vec![0.0_f64; n]; n];
305        for i in 0..n {
306            for j in 0..n {
307                let r = Self::dist(&self.points[i], &self.points[j]);
308                g[i][j] = inv_multiquadric(r, eps);
309            }
310        }
311        self.l = Self::full_cholesky(&g)?;
312        self.coeffs_dirty = true;
313
314        Ok(())
315    }
316
317    /// Predict the interpolated value at a new point `x`.
318    pub fn predict(&mut self, x: &[f64]) -> Result<f64, InterpolateError> {
319        if self.points.is_empty() {
320            return Err(InterpolateError::InvalidState(
321                "StreamingRbf has no data yet".to_string(),
322            ));
323        }
324        self.refresh_coeffs();
325        let val: f64 = self
326            .points
327            .iter()
328            .zip(self.coeffs.iter())
329            .map(|(pt, &alpha)| {
330                let r = Self::dist(x, pt);
331                alpha * inv_multiquadric(r, self.config.shape_param)
332            })
333            .sum();
334        Ok(val)
335    }
336}
337
338// ─────────────────────────────────────────────────────────────────────────────
339// Tests
340// ─────────────────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::f64::consts::PI;
346
347    #[test]
348    fn test_streaming_sin_prediction() {
349        let mut rbf = StreamingRbf::new(StreamingRbfConfig {
350            window_size: 20,
351            shape_param: 1.0,
352            forget_factor: 1.0, // no forgetting for this test
353        });
354
355        // Add 10 points from y = sin(x) on [0, π]
356        for i in 0..10 {
357            let xi = (i as f64) * PI / 9.0;
358            rbf.update(vec![xi], xi.sin())
359                .expect("update should succeed");
360        }
361
362        assert_eq!(rbf.n_points(), 10);
363
364        // Predict at x = π/4
365        let test_x = PI / 4.0;
366        let pred = rbf.predict(&[test_x]).expect("predict should succeed");
367        let expected = test_x.sin();
368        assert!(
369            (pred - expected).abs() < 0.1,
370            "sin prediction off: pred={:.4}, expected={:.4}",
371            pred,
372            expected
373        );
374    }
375
376    #[test]
377    fn test_window_eviction() {
378        let window = 5;
379        let mut rbf = StreamingRbf::new(StreamingRbfConfig {
380            window_size: window,
381            shape_param: 1.0,
382            forget_factor: 1.0,
383        });
384
385        for i in 0..10 {
386            let xi = i as f64 * 0.1;
387            rbf.update(vec![xi], xi * xi).expect("update");
388        }
389
390        assert_eq!(
391            rbf.n_points(),
392            window,
393            "window should be capped at {window}"
394        );
395    }
396
397    #[test]
398    fn test_predict_empty_returns_error() {
399        let mut rbf = StreamingRbf::new(StreamingRbfConfig::default());
400        let result = rbf.predict(&[0.5]);
401        assert!(result.is_err());
402    }
403
404    #[test]
405    fn test_forget_factor_effect() {
406        // With strong forgetting, recent data dominates.
407        let mut rbf = StreamingRbf::new(StreamingRbfConfig {
408            window_size: 100,
409            shape_param: 1.0,
410            forget_factor: 0.5,
411        });
412
413        // Add old data: f = 0
414        for i in 0..5 {
415            rbf.update(vec![i as f64 * 0.2], 0.0).expect("update old");
416        }
417        // Add recent data: f = 1
418        for i in 0..5 {
419            rbf.update(vec![i as f64 * 0.2 + 0.05], 1.0)
420                .expect("update new");
421        }
422
423        // Prediction should be closer to 1 than 0 since old data is discounted.
424        let pred = rbf.predict(&[0.5]).expect("predict");
425        assert!(
426            pred > 0.0,
427            "prediction with forgetting should lean towards recent data, got {pred}"
428        );
429    }
430
431    #[test]
432    fn test_cholesky_rank1_update_basic() {
433        // Build a 1×1 Cholesky factor, then extend to 2×2 via rank-1 update.
434        // Use a manually constructed 2×2 SPD Gram matrix:
435        //   G = [[4.0, 1.0], [1.0, 4.0]]
436        // Cholesky: L[0][0] = 2.0
437        //           L[1][0] = 0.5, L[1][1] = sqrt(4 - 0.25) = sqrt(3.75)
438        let g00 = 4.0_f64;
439        let g01 = 1.0_f64;
440        let g11 = 4.0_f64;
441
442        // Initial 1×1 L from 1×1 G = [[4.0]].
443        let mut l = vec![vec![g00.sqrt()]]; // [[2.0]]
444
445        // v for extending: [g01, g11] = [1.0, 4.0]
446        let v = vec![g01, g11];
447        StreamingRbf::cholesky_rank1_update(&mut l, &v).expect("rank-1 update");
448
449        assert_eq!(l.len(), 2);
450
451        // Verify L Lᵀ ≈ G
452        // G[0][0] = l[0][0]²
453        // G[1][0] = l[1][0] * l[0][0]
454        // G[1][1] = l[1][0]² + l[1][1]²
455        let rec_g00 = l[0][0] * l[0][0];
456        let rec_g10 = l[1][0] * l[0][0];
457        let rec_g11 = l[1][0] * l[1][0] + l[1][1] * l[1][1];
458        assert!(
459            (rec_g00 - g00).abs() < 1e-10,
460            "G[0][0] mismatch: {} vs {}",
461            rec_g00,
462            g00
463        );
464        assert!(
465            (rec_g10 - g01).abs() < 1e-10,
466            "G[1][0] mismatch: {} vs {}",
467            rec_g10,
468            g01
469        );
470        assert!(
471            (rec_g11 - g11).abs() < 1e-10,
472            "G[1][1] mismatch: {} vs {}",
473            rec_g11,
474            g11
475        );
476    }
477}