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}