Skip to main content

scirs2_integrate/
sde_simple.rs

1//! Simplified SDE solvers and Fokker-Planck equation
2//!
3//! This module provides self-contained, easy-to-use stochastic differential
4//! equation solvers with a minimal API. For production-grade SDE simulation,
5//! see the `sde` module which uses the full scirs2-core random number infrastructure.
6//!
7//! ## Contents
8//!
9//! - [`SimpleRng`] — deterministic seeded RNG (Xoshiro256+)
10//! - [`EulerMaruyama`] — Euler-Maruyama strong order 0.5 solver
11//! - [`MilsteinSolver`] — Milstein strong order 1.0 scalar solver
12//! - [`solve_sde`] — convenience function for multiple sample paths
13//! - [`FokkerPlanckSolver`] — finite-difference PDE solver for the FP equation
14//!
15//! ## Background
16//!
17//! An n-dimensional SDE with m-dimensional Wiener process:
18//! ```text
19//! dX = f(X, t) dt + G(X, t) dW,   X(t₀) = X₀
20//! ```
21//! where:
22//! - `f: ℝⁿ × ℝ → ℝⁿ` is the drift
23//! - `G: ℝⁿ × ℝ → ℝⁿˣᵐ` is the diffusion matrix
24//! - `W` is an m-dimensional standard Wiener process
25//!
26//! ## Example
27//!
28//! ```rust
29//! use scirs2_integrate::sde_simple::{EulerMaruyama, SimpleRng, solve_sde};
30//!
31//! // Geometric Brownian Motion: dX = μ X dt + σ X dW
32//! let mu = 0.05_f64;
33//! let sigma = 0.2_f64;
34//!
35//! let solver = EulerMaruyama::new(
36//!     move |x: &[f64], _t: f64| vec![mu * x[0]],
37//!     move |x: &[f64], _t: f64| vec![vec![sigma * x[0]]],
38//!     1, 1,
39//! );
40//!
41//! let sol = solve_sde(&solver, &[100.0], 0.0, 1.0, 0.01, 5, 42);
42//! assert_eq!(sol.paths.len(), 5);
43//! assert!(!sol.times.is_empty());
44//! ```
45
46use crate::error::{IntegrateError, IntegrateResult};
47
48// ─────────────────────────────────────────────────────────────────────────────
49// Minimal PRNG: Xoshiro256+ (fast, high quality)
50// ─────────────────────────────────────────────────────────────────────────────
51
52/// Lightweight seeded pseudo-random number generator based on Xoshiro256+.
53///
54/// Provides uniform and standard-normal samples without any external dependencies.
55pub struct SimpleRng {
56    state: [u64; 4],
57}
58
59impl SimpleRng {
60    /// Create a new RNG initialised from `seed`.
61    pub fn new(seed: u64) -> Self {
62        // Seed expansion via splitmix64
63        let mut s = seed;
64        let expand = |x: &mut u64| -> u64 {
65            *x = x.wrapping_add(0x9e3779b97f4a7c15);
66            let mut z = *x;
67            z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
68            z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
69            z ^ (z >> 31)
70        };
71        Self {
72            state: [
73                expand(&mut s),
74                expand(&mut s),
75                expand(&mut s),
76                expand(&mut s),
77            ],
78        }
79    }
80
81    /// Generate a random u64 using the Xoshiro256+ algorithm.
82    fn next_u64(&mut self) -> u64 {
83        let result = self.state[0].wrapping_add(self.state[3]);
84        let t = self.state[1] << 17;
85        self.state[2] ^= self.state[0];
86        self.state[3] ^= self.state[1];
87        self.state[1] ^= self.state[2];
88        self.state[0] ^= self.state[3];
89        self.state[2] ^= t;
90        self.state[3] = self.state[3].rotate_left(45);
91        result
92    }
93
94    /// Sample a uniform float in [0, 1).
95    pub fn next_f64(&mut self) -> f64 {
96        // Use upper 53 bits for uniform [0,1)
97        (self.next_u64() >> 11) as f64 * (1.0_f64 / (1u64 << 53) as f64)
98    }
99
100    /// Sample from the standard normal distribution N(0, 1) via Box-Muller.
101    pub fn next_normal(&mut self) -> f64 {
102        loop {
103            let u1 = self.next_f64();
104            let u2 = self.next_f64();
105            if u1 > f64::EPSILON {
106                let r = (-2.0 * u1.ln()).sqrt();
107                let theta = 2.0 * std::f64::consts::PI * u2;
108                return r * theta.cos();
109            }
110        }
111    }
112
113    /// Sample an m-dimensional Brownian increment √dt · Z where Z ~ N(0, I_m).
114    pub fn brownian_increment(&mut self, m: usize, dt: f64) -> Vec<f64> {
115        let sqrt_dt = dt.sqrt();
116        (0..m).map(|_| sqrt_dt * self.next_normal()).collect()
117    }
118}
119
120// ─────────────────────────────────────────────────────────────────────────────
121// SDE solver trait
122// ─────────────────────────────────────────────────────────────────────────────
123
124/// Common interface for SDE stepping methods.
125pub trait SdeSolver {
126    /// Advance the state `x` at time `t` by one step of size `dt`.
127    fn step(&self, x: &[f64], t: f64, dt: f64, rng: &mut SimpleRng) -> Vec<f64>;
128}
129
130// ─────────────────────────────────────────────────────────────────────────────
131// Euler-Maruyama
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// Euler-Maruyama discretisation of an n-dimensional SDE (strong order 0.5).
135///
136/// Update rule:
137/// ```text
138/// X(t+dt) = X(t) + f(X,t) dt + G(X,t) ΔW
139/// ```
140/// where `ΔW ~ N(0, dt I_m)`.
141pub struct EulerMaruyama {
142    drift: Box<dyn Fn(&[f64], f64) -> Vec<f64> + Send + Sync>,
143    diffusion: Box<dyn Fn(&[f64], f64) -> Vec<Vec<f64>> + Send + Sync>,
144    /// State-space dimension n
145    pub n_dim: usize,
146    /// Wiener process dimension m
147    pub m_noise: usize,
148}
149
150impl EulerMaruyama {
151    /// Create a new Euler-Maruyama solver.
152    ///
153    /// # Arguments
154    /// * `drift` — drift function `f(x, t) -> Vec<f64>` of length `n_dim`
155    /// * `diffusion` — diffusion matrix `G(x, t) -> Vec<Vec<f64>>` of shape `[n_dim][m_noise]`
156    /// * `n_dim` — dimension of the state space
157    /// * `m_noise` — number of independent Brownian motions
158    pub fn new(
159        drift: impl Fn(&[f64], f64) -> Vec<f64> + Send + Sync + 'static,
160        diffusion: impl Fn(&[f64], f64) -> Vec<Vec<f64>> + Send + Sync + 'static,
161        n_dim: usize,
162        m_noise: usize,
163    ) -> Self {
164        Self {
165            drift: Box::new(drift),
166            diffusion: Box::new(diffusion),
167            n_dim,
168            m_noise,
169        }
170    }
171}
172
173impl SdeSolver for EulerMaruyama {
174    /// One Euler-Maruyama step.
175    fn step(&self, x: &[f64], t: f64, dt: f64, rng: &mut SimpleRng) -> Vec<f64> {
176        let f = (self.drift)(x, t);
177        let g = (self.diffusion)(x, t);
178        let dw = rng.brownian_increment(self.m_noise, dt);
179
180        let mut x_new = vec![0.0_f64; self.n_dim];
181        for i in 0..self.n_dim {
182            x_new[i] = x[i] + f[i] * dt;
183            for j in 0..self.m_noise {
184                if i < g.len() && j < g[i].len() {
185                    x_new[i] += g[i][j] * dw[j];
186                }
187            }
188        }
189        x_new
190    }
191}
192
193// ─────────────────────────────────────────────────────────────────────────────
194// Milstein solver (scalar)
195// ─────────────────────────────────────────────────────────────────────────────
196
197/// Milstein method for a scalar SDE (strong order 1.0).
198///
199/// Update rule for the 1D SDE `dX = f(X,t) dt + g(X,t) dW`:
200/// ```text
201/// X(t+dt) = X(t) + f dt + g ΔW + 0.5 g g' (ΔW² - dt)
202/// ```
203/// where `g' = ∂g/∂X`.
204pub struct MilsteinSolver {
205    drift: Box<dyn Fn(f64, f64) -> f64 + Send + Sync>,
206    diffusion: Box<dyn Fn(f64, f64) -> f64 + Send + Sync>,
207    diffusion_deriv: Box<dyn Fn(f64, f64) -> f64 + Send + Sync>,
208}
209
210impl MilsteinSolver {
211    /// Create a Milstein solver for a scalar SDE.
212    ///
213    /// # Arguments
214    /// * `drift` — `f(x, t)` (scalar drift)
215    /// * `diffusion` — `g(x, t)` (scalar diffusion coefficient)
216    /// * `diffusion_deriv` — `∂g/∂x(x, t)` (derivative of g with respect to state)
217    pub fn new(
218        drift: impl Fn(f64, f64) -> f64 + Send + Sync + 'static,
219        diffusion: impl Fn(f64, f64) -> f64 + Send + Sync + 'static,
220        diffusion_deriv: impl Fn(f64, f64) -> f64 + Send + Sync + 'static,
221    ) -> Self {
222        Self {
223            drift: Box::new(drift),
224            diffusion: Box::new(diffusion),
225            diffusion_deriv: Box::new(diffusion_deriv),
226        }
227    }
228
229    /// One Milstein step for the scalar SDE.
230    pub fn step_scalar(&self, x: f64, t: f64, dt: f64, rng: &mut SimpleRng) -> f64 {
231        let dw = rng.next_normal() * dt.sqrt();
232        let f = (self.drift)(x, t);
233        let g = (self.diffusion)(x, t);
234        let gp = (self.diffusion_deriv)(x, t);
235        x + f * dt + g * dw + 0.5 * g * gp * (dw * dw - dt)
236    }
237}
238
239impl SdeSolver for MilsteinSolver {
240    /// Milstein step adapted to the generic `SdeSolver` trait (scalar SDE, n=1, m=1).
241    fn step(&self, x: &[f64], t: f64, dt: f64, rng: &mut SimpleRng) -> Vec<f64> {
242        let x0 = if x.is_empty() { 0.0 } else { x[0] };
243        vec![self.step_scalar(x0, t, dt, rng)]
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// SDE solution container
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Container for multiple sample paths of an SDE solution.
252pub struct SdeSolution {
253    /// Time grid: `times[k]` = time at step k
254    pub times: Vec<f64>,
255    /// Sample paths: `paths[path_idx][step_idx * n_dim + dim_idx]`
256    /// For n_dim=1: `paths[path_idx][step_idx]` = X(t_k) for path `path_idx`
257    pub paths: Vec<Vec<f64>>,
258    /// State space dimension
259    pub n_dim: usize,
260}
261
262impl SdeSolution {
263    /// Get the full trajectory of a single sample path as `(times, values)`.
264    ///
265    /// `values[k]` is the n_dim-dimensional state at `times[k]`.
266    pub fn path(&self, idx: usize) -> Option<(&[f64], Vec<Vec<f64>>)> {
267        if idx >= self.paths.len() {
268            return None;
269        }
270        let n_steps = self.times.len();
271        let n_dim = self.n_dim;
272        let flat = &self.paths[idx];
273        let states: Vec<Vec<f64>> = (0..n_steps)
274            .map(|k| flat[k * n_dim..(k + 1) * n_dim].to_vec())
275            .collect();
276        Some((&self.times, states))
277    }
278
279    /// Compute the mean trajectory (element-wise average over paths) at each time step.
280    pub fn mean_trajectory(&self) -> Vec<Vec<f64>> {
281        let n_steps = self.times.len();
282        let n_dim = self.n_dim;
283        let n_paths = self.paths.len();
284        if n_paths == 0 || n_steps == 0 {
285            return vec![];
286        }
287        let scale = 1.0 / n_paths as f64;
288        (0..n_steps)
289            .map(|k| {
290                (0..n_dim)
291                    .map(|d| self.paths.iter().map(|p| p[k * n_dim + d]).sum::<f64>() * scale)
292                    .collect()
293            })
294            .collect()
295    }
296
297    /// Compute the variance of each dimension across sample paths at each time step.
298    pub fn variance_trajectory(&self) -> Vec<Vec<f64>> {
299        let n_steps = self.times.len();
300        let n_dim = self.n_dim;
301        let n_paths = self.paths.len();
302        if n_paths < 2 || n_steps == 0 {
303            return vec![vec![0.0; n_dim]; n_steps];
304        }
305        let mean = self.mean_trajectory();
306        let scale = 1.0 / (n_paths - 1) as f64;
307        (0..n_steps)
308            .map(|k| {
309                (0..n_dim)
310                    .map(|d| {
311                        self.paths
312                            .iter()
313                            .map(|p| {
314                                let diff = p[k * n_dim + d] - mean[k][d];
315                                diff * diff
316                            })
317                            .sum::<f64>()
318                            * scale
319                    })
320                    .collect()
321            })
322            .collect()
323    }
324}
325
326// ─────────────────────────────────────────────────────────────────────────────
327// Convenience solver function
328// ─────────────────────────────────────────────────────────────────────────────
329
330/// Simulate `n_paths` independent sample paths of an SDE using the given solver.
331///
332/// # Arguments
333/// * `solver` — any type implementing `SdeSolver`
334/// * `x0` — initial condition (n_dim-dimensional)
335/// * `t0` — initial time
336/// * `t_final` — final time
337/// * `dt` — time step size
338/// * `n_paths` — number of independent sample paths
339/// * `seed` — base seed (each path uses `seed + path_index`)
340///
341/// # Returns
342/// An `SdeSolution` containing all time steps and path data.
343pub fn solve_sde(
344    solver: &impl SdeSolver,
345    x0: &[f64],
346    t0: f64,
347    t_final: f64,
348    dt: f64,
349    n_paths: usize,
350    seed: u64,
351) -> SdeSolution {
352    let n_dim = x0.len();
353    let n_steps = ((t_final - t0) / dt).ceil() as usize + 1;
354
355    let mut times = Vec::with_capacity(n_steps);
356    let mut t = t0;
357    while t <= t_final + dt * 0.5 {
358        times.push(t);
359        t += dt;
360        if times.len() >= n_steps {
361            break;
362        }
363    }
364    // Ensure final time is included
365    if times.last().copied().unwrap_or(t0) < t_final - f64::EPSILON {
366        times.push(t_final);
367    }
368
369    let actual_steps = times.len();
370    let mut paths = Vec::with_capacity(n_paths);
371
372    for path_idx in 0..n_paths {
373        let mut rng = SimpleRng::new(seed.wrapping_add(path_idx as u64));
374        let mut path_data = Vec::with_capacity(actual_steps * n_dim);
375        // Store initial state
376        path_data.extend_from_slice(x0);
377        let mut x = x0.to_vec();
378        for k in 1..actual_steps {
379            let dt_actual = times[k] - times[k - 1];
380            x = solver.step(&x, times[k - 1], dt_actual, &mut rng);
381            path_data.extend_from_slice(&x);
382        }
383        paths.push(path_data);
384    }
385
386    SdeSolution {
387        times,
388        paths,
389        n_dim,
390    }
391}
392
393// ─────────────────────────────────────────────────────────────────────────────
394// Fokker-Planck solver
395// ─────────────────────────────────────────────────────────────────────────────
396
397/// Finite-difference solver for the 1D Fokker-Planck equation.
398///
399/// Evolves the probability density function p(x, t) according to:
400/// ```text
401/// ∂p/∂t = -∂/∂x [f(x) p] + (1/2) ∂²/∂x² [g²(x) p]
402/// ```
403/// where `f` is the drift and `g` is the diffusion coefficient of the SDE.
404///
405/// The FP equation governs the time evolution of the marginal distribution of
406/// the SDE state. The probability density is kept non-negative and normalised
407/// throughout the integration.
408///
409/// ## Discretisation
410///
411/// Uses the Chang-Cooper upwind finite-difference scheme which preserves
412/// positivity and maintains the stationary distribution exactly. The simpler
413/// explicit Lax-Wendroff scheme is used here for ease of implementation.
414pub struct FokkerPlanckSolver {
415    /// Number of grid points
416    pub n: usize,
417    /// Grid spacing
418    pub dx: f64,
419    /// Grid point coordinates
420    pub x: Vec<f64>,
421    /// Probability density function p(x)
422    pub pdf: Vec<f64>,
423}
424
425impl FokkerPlanckSolver {
426    /// Create a new Fokker-Planck solver on [x_min, x_max].
427    ///
428    /// # Arguments
429    /// * `x_min`, `x_max` — domain boundaries
430    /// * `n` — number of grid points
431    /// * `initial_pdf` — initial probability density (will be normalised)
432    ///
433    /// # Errors
434    /// Returns an error if `initial_pdf.len() != n` or `n < 3`.
435    pub fn new(x_min: f64, x_max: f64, n: usize, initial_pdf: &[f64]) -> IntegrateResult<Self> {
436        if n < 3 {
437            return Err(IntegrateError::ValueError(
438                "FokkerPlanck: need at least 3 grid points".into(),
439            ));
440        }
441        if initial_pdf.len() != n {
442            return Err(IntegrateError::ValueError(format!(
443                "FokkerPlanck: initial_pdf length {} != n={}",
444                initial_pdf.len(),
445                n
446            )));
447        }
448        let dx = (x_max - x_min) / ((n - 1) as f64);
449        let x: Vec<f64> = (0..n).map(|i| x_min + i as f64 * dx).collect();
450
451        // Normalise the initial PDF
452        let integral: f64 = initial_pdf.iter().sum::<f64>() * dx;
453        let pdf: Vec<f64> = if integral > 1e-15 {
454            initial_pdf.iter().map(|&p| p.max(0.0) / integral).collect()
455        } else {
456            vec![1.0 / (x_max - x_min); n]
457        };
458
459        Ok(Self { n, dx, x, pdf })
460    }
461
462    /// Advance the PDF by one step using the pre-computed drift and diffusion arrays.
463    ///
464    /// This uses a forward-backward difference scheme for the advection term
465    /// (upwind in drift direction) and central differences for diffusion.
466    ///
467    /// # Arguments
468    /// * `dt` — time step (must satisfy stability: dt < dx² / max(g²))
469    /// * `drift` — precomputed `f(x_i)` values at all grid points
470    /// * `diffusion` — precomputed `g(x_i)` values at all grid points
471    pub fn step(&mut self, dt: f64, drift: &[f64], diffusion: &[f64]) {
472        let n = self.n;
473        let dx = self.dx;
474        let inv_dx = 1.0 / dx;
475        let inv_dx2 = inv_dx * inv_dx;
476
477        let mut p_new = vec![0.0_f64; n];
478
479        for i in 1..n - 1 {
480            let p = self.pdf[i];
481            let pp = self.pdf[i + 1];
482            let pm = self.pdf[i - 1];
483
484            // Advection (upwind): -∂/∂x [f p]
485            let f = drift[i];
486            let adv = if f >= 0.0 {
487                -f * (p - pm) * inv_dx
488            } else {
489                -f * (pp - p) * inv_dx
490            };
491
492            // Diffusion: 0.5 ∂²/∂x² [g² p]
493            let g2_p = diffusion[i] * diffusion[i] * p;
494            let g2_pp = diffusion[i + 1] * diffusion[i + 1] * pp;
495            let g2_pm = diffusion[i - 1] * diffusion[i - 1] * pm;
496            let diff = 0.5 * (g2_pp - 2.0 * g2_p + g2_pm) * inv_dx2;
497
498            p_new[i] = p + dt * (adv + diff);
499        }
500
501        // Neumann (reflecting) boundary conditions — zero flux
502        p_new[0] = p_new[1];
503        p_new[n - 1] = p_new[n - 2];
504
505        // Ensure non-negativity
506        for v in p_new.iter_mut() {
507            if *v < 0.0 {
508                *v = 0.0;
509            }
510        }
511
512        // Renormalise to preserve probability
513        let total: f64 = p_new.iter().sum::<f64>() * dx;
514        if total > 1e-15 {
515            for v in p_new.iter_mut() {
516                *v /= total;
517            }
518        }
519
520        self.pdf = p_new;
521    }
522
523    /// Run the Fokker-Planck equation for `n_steps` steps.
524    pub fn run(
525        &mut self,
526        n_steps: usize,
527        dt: f64,
528        drift_fn: impl Fn(f64) -> f64,
529        diffusion_fn: impl Fn(f64) -> f64,
530    ) {
531        let drift: Vec<f64> = self.x.iter().map(|&xi| drift_fn(xi)).collect();
532        let diffusion: Vec<f64> = self.x.iter().map(|&xi| diffusion_fn(xi)).collect();
533        for _ in 0..n_steps {
534            self.step(dt, &drift, &diffusion);
535        }
536    }
537
538    /// Compute the mean ⟨X⟩ = ∫ x p(x) dx.
539    pub fn mean(&self) -> f64 {
540        self.x
541            .iter()
542            .zip(self.pdf.iter())
543            .map(|(&xi, &pi)| xi * pi)
544            .sum::<f64>()
545            * self.dx
546    }
547
548    /// Compute the variance Var\[X\] = ⟨X²⟩ − ⟨X⟩².
549    pub fn variance(&self) -> f64 {
550        let mu = self.mean();
551        let ex2: f64 = self
552            .x
553            .iter()
554            .zip(self.pdf.iter())
555            .map(|(&xi, &pi)| xi * xi * pi)
556            .sum::<f64>()
557            * self.dx;
558        (ex2 - mu * mu).max(0.0)
559    }
560
561    /// Compute the differential entropy −∫ p(x) ln p(x) dx.
562    pub fn entropy(&self) -> f64 {
563        -self
564            .pdf
565            .iter()
566            .filter(|&&p| p > 1e-300)
567            .map(|&p| p * p.ln())
568            .sum::<f64>()
569            * self.dx
570    }
571
572    /// Compute the L1 norm ∫ |p(x)| dx (should be ≈ 1 after normalisation).
573    pub fn l1_norm(&self) -> f64 {
574        self.pdf.iter().sum::<f64>() * self.dx
575    }
576}
577
578// ─────────────────────────────────────────────────────────────────────────────
579// Unit tests
580// ─────────────────────────────────────────────────────────────────────────────
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    // ── SimpleRng ──────────────────────────────────────────────────────────
587
588    #[test]
589    fn test_rng_uniform_range() {
590        let mut rng = SimpleRng::new(42);
591        for _ in 0..1000 {
592            let v = rng.next_f64();
593            assert!((0.0..1.0).contains(&v));
594        }
595    }
596
597    #[test]
598    fn test_rng_normal_zero_mean() {
599        let mut rng = SimpleRng::new(7);
600        let samples: Vec<f64> = (0..10_000).map(|_| rng.next_normal()).collect();
601        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
602        assert!(mean.abs() < 0.1, "mean={}", mean);
603    }
604
605    #[test]
606    fn test_rng_normal_unit_variance() {
607        let mut rng = SimpleRng::new(13);
608        let samples: Vec<f64> = (0..10_000).map(|_| rng.next_normal()).collect();
609        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
610        let var = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / samples.len() as f64;
611        assert!((var - 1.0).abs() < 0.05, "var={}", var);
612    }
613
614    #[test]
615    fn test_rng_seeded_deterministic() {
616        let mut r1 = SimpleRng::new(99);
617        let mut r2 = SimpleRng::new(99);
618        for _ in 0..100 {
619            assert_eq!(r1.next_u64(), r2.next_u64());
620        }
621    }
622
623    // ── Euler-Maruyama ─────────────────────────────────────────────────────
624
625    #[test]
626    fn test_em_brownian_motion() {
627        // dX = dW, X(0) = 0  →  E[X(T)] = 0, Var[X(T)] ≈ T
628        let solver = EulerMaruyama::new(
629            |_x: &[f64], _t: f64| vec![0.0],
630            |_x: &[f64], _t: f64| vec![vec![1.0]],
631            1,
632            1,
633        );
634        let sol = solve_sde(&solver, &[0.0], 0.0, 1.0, 0.01, 1000, 42);
635        let mean_traj = sol.mean_trajectory();
636        let final_mean = mean_traj.last().map(|v| v[0]).unwrap_or(f64::NAN);
637        let var_traj = sol.variance_trajectory();
638        let final_var = var_traj.last().map(|v| v[0]).unwrap_or(f64::NAN);
639        assert!(final_mean.abs() < 0.15, "E[W(1)] ≈ 0, got {}", final_mean);
640        assert!(
641            (final_var - 1.0).abs() < 0.15,
642            "Var[W(1)] ≈ 1, got {}",
643            final_var
644        );
645    }
646
647    #[test]
648    fn test_em_gbm_mean() {
649        // GBM: dX = μX dt + σX dW  →  E[X(T)] = X₀ exp(μT)
650        let mu = 0.1_f64;
651        let sigma = 0.2_f64;
652        let x0 = 1.0_f64;
653        let t_final = 0.5_f64;
654
655        let solver = EulerMaruyama::new(
656            move |x: &[f64], _t: f64| vec![mu * x[0]],
657            move |x: &[f64], _t: f64| vec![vec![sigma * x[0]]],
658            1,
659            1,
660        );
661        let sol = solve_sde(&solver, &[x0], 0.0, t_final, 0.01, 2000, 7);
662        let mean_traj = sol.mean_trajectory();
663        let final_mean = mean_traj.last().map(|v| v[0]).unwrap_or(f64::NAN);
664        let expected = x0 * (mu * t_final).exp();
665        let rel_err = (final_mean - expected).abs() / expected;
666        assert!(
667            rel_err < 0.05,
668            "E[GBM] rel_err={:.3}, got={:.4} exp={:.4}",
669            rel_err,
670            final_mean,
671            expected
672        );
673    }
674
675    // ── Milstein ───────────────────────────────────────────────────────────
676
677    #[test]
678    fn test_milstein_gbm_order() {
679        // Milstein should give smaller strong error than EM for GBM
680        let mu = 0.05_f64;
681        let sigma = 0.3_f64;
682        let x0 = 1.0_f64;
683
684        let solver = MilsteinSolver::new(
685            move |x: f64, _t: f64| mu * x,
686            move |x: f64, _t: f64| sigma * x,
687            move |_x: f64, _t: f64| sigma, // dg/dx = sigma (g = sigma*x)
688        );
689        let sol = solve_sde(&solver, &[x0], 0.0, 0.5, 0.01, 500, 42);
690        assert!(!sol.times.is_empty());
691        // Mean should be near exp(mu*T)
692        let mean = sol.mean_trajectory();
693        let last = mean.last().map(|v| v[0]).unwrap_or(f64::NAN);
694        assert!(last > 0.0 && last.is_finite());
695    }
696
697    // ── SdeSolution ────────────────────────────────────────────────────────
698
699    #[test]
700    fn test_solution_path_access() {
701        let solver = EulerMaruyama::new(
702            |_x: &[f64], _t: f64| vec![0.0],
703            |_x: &[f64], _t: f64| vec![vec![1.0]],
704            1,
705            1,
706        );
707        let sol = solve_sde(&solver, &[0.0], 0.0, 0.1, 0.01, 3, 1);
708        assert_eq!(sol.paths.len(), 3);
709        assert!(sol.path(0).is_some());
710        assert!(sol.path(10).is_none());
711    }
712
713    // ── Fokker-Planck ──────────────────────────────────────────────────────
714
715    #[test]
716    fn test_fp_creation() {
717        let n = 50;
718        let pdf: Vec<f64> = (0..n)
719            .map(|i| {
720                let x = -5.0 + 10.0 * i as f64 / (n - 1) as f64;
721                (-(x * x) / 2.0).exp()
722            })
723            .collect();
724        let solver = FokkerPlanckSolver::new(-5.0, 5.0, n, &pdf);
725        assert!(solver.is_ok());
726    }
727
728    #[test]
729    fn test_fp_wrong_size() {
730        let pdf = vec![1.0; 10];
731        let solver = FokkerPlanckSolver::new(-5.0, 5.0, 20, &pdf);
732        assert!(solver.is_err());
733    }
734
735    #[test]
736    fn test_fp_normalisation_preserved() {
737        let n = 50;
738        let pdf: Vec<f64> = (0..n)
739            .map(|i| {
740                let x = -5.0 + 10.0 * i as f64 / (n - 1) as f64;
741                (-(x * x) / 2.0).exp()
742            })
743            .collect();
744        let mut solver = FokkerPlanckSolver::new(-5.0, 5.0, n, &pdf)
745            .expect("FokkerPlanckSolver::new should succeed with valid params");
746        solver.run(20, 1e-3, |x| -x, |_| 1.0); // Ornstein-Uhlenbeck
747        let norm = solver.l1_norm();
748        assert!((norm - 1.0).abs() < 0.01, "L1 norm = {}", norm);
749    }
750
751    #[test]
752    fn test_fp_ou_mean_mean_reversion() {
753        // OU process: dX = -X dt + dW, stationary dist N(0,0.5)
754        // Start from δ(x-2), should move towards 0
755        let n = 101;
756        let center_idx = 85usize; // near x=2 in [-5,5]
757        let mut pdf = vec![0.0_f64; n];
758        pdf[center_idx] = 1.0 / (10.0 / n as f64); // delta-like spike
759        let mut solver = FokkerPlanckSolver::new(-5.0, 5.0, n, &pdf)
760            .expect("FokkerPlanckSolver::new should succeed with valid params");
761        let m0 = solver.mean();
762        solver.run(100, 1e-3, |x| -x, |_| 1.0);
763        let m1 = solver.mean();
764        // Mean should have moved towards 0 (from positive side)
765        assert!(
766            m1.abs() < m0.abs() + 0.5,
767            "mean should move towards 0: m0={:.3} m1={:.3}",
768            m0,
769            m1
770        );
771    }
772
773    #[test]
774    fn test_fp_variance_finite() {
775        let n = 50;
776        let pdf = vec![1.0_f64 / 50.0; n]; // uniform
777        let mut solver = FokkerPlanckSolver::new(-1.0, 1.0, n, &pdf)
778            .expect("FokkerPlanckSolver::new should succeed with valid params");
779        solver.run(10, 1e-4, |_| 0.0, |_| 0.5);
780        let v = solver.variance();
781        assert!(v >= 0.0 && v.is_finite(), "variance={}", v);
782    }
783}