Skip to main content

qualia_core_db/solvers/calculus/
ode_advanced.rs

1//! Advanced ODE integrators: symplectic, stiff (BDF), dense output, and forward
2//! sensitivity — the doctorate-level capabilities the `ode_solver` scalar RK4/BVP
3//! core does not provide.
4//!
5//! Kept in its own focused module (rather than growing the 1000-line `ode_solver.rs`
6//! monolith) per the split rule. **Zero-heap**: every routine is pure-scalar
7//! arithmetic over `f64`, parameterised by closures (`impl Fn`) — no `Vec`/`Box`,
8//! no allocation. Suitable for the constrained / off-grid core.
9//!
10//! ## What lives here
11//! 1. **Symplectic integrators** (`verlet_step`, `ruth3_step`, `yoshida4_step`,
12//!    `integrate_symplectic`) for separable Hamiltonian systems `H(q,p)=T(p)+V(q)` —
13//!    they conserve energy with *bounded oscillation* over millions of steps instead
14//!    of the secular drift a non-symplectic method (RK4) shows.
15//! 2. **Stiff BDF solvers** (`bdf1_step`, `bdf2_step`, `integrate_bdf`) — L-stable
16//!    backward-differentiation formulas with a Newton corrector, for stiff
17//!    thermodynamic / phase-transition ODEs where explicit methods blow up.
18//! 3. **Dense output** (`hermite_dense_output`) — cubic-Hermite continuous extension
19//!    giving the state at any `t + θΔt` without re-evaluating the derivative.
20//! 4. **Forward sensitivity** (`integrate_with_sensitivity`) — integrates the
21//!    variational equation `ds/dt = f_y·s` alongside the state to get `∂y/∂y₀`.
22
23/// Cube root of 2, used by the Yoshida 4th-order composition. Const literal so the
24/// symplectic path needs no transcendental call (portable to no_std cores).
25const CBRT2: f64 = 1.259_921_049_894_873_2;
26
27// ── 1. Symplectic integrators (separable Hamiltonian H = T(p) + V(q)) ───────────
28//
29// Hamilton's equations: dq/dt = ∂T/∂p = `kinetic_velocity(p)`,
30//                       dp/dt = -∂V/∂q = `force(q)`.
31
32/// One Störmer–Verlet (velocity-Verlet / leapfrog) step — 2nd-order symplectic and
33/// time-reversible. `force(q) = -∂V/∂q`, `kinetic_velocity(p) = ∂T/∂p` (= p/m).
34pub fn verlet_step<F, G>(q: f64, p: f64, h: f64, force: F, kinetic_velocity: G) -> (f64, f64)
35where
36    F: Fn(f64) -> f64,
37    G: Fn(f64) -> f64,
38{
39    let p_half = p + 0.5 * h * force(q);
40    let q_new = q + h * kinetic_velocity(p_half);
41    let p_new = p_half + 0.5 * h * force(q_new);
42    (q_new, p_new)
43}
44
45/// One Ruth (1983) 3rd-order symplectic step. Three (kick, drift) sub-stages with
46/// the canonical Ruth coefficients.
47pub fn ruth3_step<F, G>(q: f64, p: f64, h: f64, force: F, kinetic_velocity: G) -> (f64, f64)
48where
49    F: Fn(f64) -> f64,
50    G: Fn(f64) -> f64,
51{
52    // c = drift weights, d = kick weights (Ruth 1983).
53    const C: [f64; 3] = [1.0, -2.0 / 3.0, 2.0 / 3.0];
54    const D: [f64; 3] = [-1.0 / 24.0, 3.0 / 4.0, 7.0 / 24.0];
55    let mut q = q;
56    let mut p = p;
57    for i in 0..3 {
58        p += C[i] * h * force(q);
59        q += D[i] * h * kinetic_velocity(p);
60    }
61    (q, p)
62}
63
64/// One Yoshida (1990) 4th-order symplectic step, built as a symmetric composition of
65/// three Verlet sub-steps with the Yoshida weights `w1, w0, w1`.
66pub fn yoshida4_step<F, G>(q: f64, p: f64, h: f64, force: F, kinetic_velocity: G) -> (f64, f64)
67where
68    F: Fn(f64) -> f64,
69    G: Fn(f64) -> f64,
70{
71    let w1 = 1.0 / (2.0 - CBRT2);
72    let w0 = -CBRT2 * w1;
73    let (q, p) = verlet_step(q, p, w1 * h, &force, &kinetic_velocity);
74    let (q, p) = verlet_step(q, p, w0 * h, &force, &kinetic_velocity);
75    verlet_step(q, p, w1 * h, &force, &kinetic_velocity)
76}
77
78/// Symplectic integrator order selector.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum SymplecticMethod {
81    /// Störmer–Verlet, 2nd order.
82    Verlet,
83    /// Ruth, 3rd order.
84    Ruth3,
85    /// Yoshida, 4th order.
86    Yoshida4,
87}
88
89/// Result of [`integrate_symplectic`]: the final phase-space point plus the maximum
90/// energy deviation seen over the run — the headline symplectic property is that this
91/// stays *bounded* (no secular drift) even over millions of steps.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct SymplecticResult {
94    pub q: f64,
95    pub p: f64,
96    pub max_energy_drift: f64,
97}
98
99/// Integrate a separable Hamiltonian system for `steps` steps of size `h`.
100///
101/// `hamiltonian(q, p)` returns the total energy (for the conservation diagnostic).
102/// Returns the final `(q, p)` and the maximum `|E - E₀|` observed. Zero-heap.
103#[allow(clippy::too_many_arguments)]
104pub fn integrate_symplectic<F, G, H>(
105    q0: f64,
106    p0: f64,
107    h: f64,
108    steps: u64,
109    force: F,
110    kinetic_velocity: G,
111    hamiltonian: H,
112    method: SymplecticMethod,
113) -> SymplecticResult
114where
115    F: Fn(f64) -> f64,
116    G: Fn(f64) -> f64,
117    H: Fn(f64, f64) -> f64,
118{
119    let mut q = q0;
120    let mut p = p0;
121    let e0 = hamiltonian(q0, p0);
122    let mut max_drift = 0.0f64;
123
124    for _ in 0..steps {
125        let (qn, pn) = match method {
126            SymplecticMethod::Verlet => verlet_step(q, p, h, &force, &kinetic_velocity),
127            SymplecticMethod::Ruth3 => ruth3_step(q, p, h, &force, &kinetic_velocity),
128            SymplecticMethod::Yoshida4 => yoshida4_step(q, p, h, &force, &kinetic_velocity),
129        };
130        q = qn;
131        p = pn;
132        let drift = (hamiltonian(q, p) - e0).abs();
133        if drift > max_drift {
134            max_drift = drift;
135        }
136    }
137
138    SymplecticResult {
139        q,
140        p,
141        max_energy_drift: max_drift,
142    }
143}
144
145// ── 2. Stiff solvers — Backward Differentiation Formulas (BDF) ──────────────────
146
147/// Tolerance / iteration cap for the BDF Newton corrector.
148const NEWTON_TOL: f64 = 1e-12;
149const NEWTON_MAX_ITERS: u32 = 64;
150
151/// Finite-difference estimate of `∂f/∂y` at `(t, y)`.
152#[inline]
153fn dfdy_fd<F: Fn(f64, f64) -> f64>(f: &F, t: f64, y: f64) -> f64 {
154    let eps = 1e-7 * y.abs().max(1.0);
155    (f(t, y + eps) - f(t, y - eps)) / (2.0 * eps)
156}
157
158/// One BDF1 (backward / implicit Euler) step: solve
159/// `y₁ = y₀ + h·f(t₁, y₁)` by Newton iteration. L-stable — the workhorse for stiff
160/// systems where explicit Euler/RK would require an impractically tiny `h`.
161pub fn bdf1_step<F: Fn(f64, f64) -> f64>(t0: f64, y0: f64, h: f64, f: F) -> f64 {
162    let t1 = t0 + h;
163    let mut y = y0 + h * f(t0, y0); // explicit-Euler predictor
164    for _ in 0..NEWTON_MAX_ITERS {
165        let g = y - y0 - h * f(t1, y);
166        let dg = 1.0 - h * dfdy_fd(&f, t1, y);
167        let dy = g / dg;
168        y -= dy;
169        if dy.abs() <= NEWTON_TOL * y.abs().max(1.0) {
170            break;
171        }
172    }
173    y
174}
175
176/// One BDF2 step: `y₂ = (4/3)y₁ − (1/3)y₀ + (2/3)h·f(t₂, y₂)`, solved by Newton.
177/// Second-order and L-stable; needs the two previous points `y1`(newer), `y0`(older).
178pub fn bdf2_step<F: Fn(f64, f64) -> f64>(t1: f64, y1: f64, y0: f64, h: f64, f: F) -> f64 {
179    let t2 = t1 + h;
180    let c = (4.0 / 3.0) * y1 - (1.0 / 3.0) * y0;
181    let beta = 2.0 / 3.0;
182    let mut y = y1 + h * f(t1, y1); // predictor
183    for _ in 0..NEWTON_MAX_ITERS {
184        let g = y - c - beta * h * f(t2, y);
185        let dg = 1.0 - beta * h * dfdy_fd(&f, t2, y);
186        let dy = g / dg;
187        y -= dy;
188        if dy.abs() <= NEWTON_TOL * y.abs().max(1.0) {
189            break;
190        }
191    }
192    y
193}
194
195/// Integrate `dy/dt = f(t,y)` over `steps` steps of size `h` with the L-stable BDF2
196/// formula, bootstrapped by one BDF1 step. Returns the final `y`. Zero-heap (keeps
197/// only the two-point history). Stable for stiff systems at large `h`.
198pub fn integrate_bdf<F: Fn(f64, f64) -> f64>(t0: f64, y0: f64, h: f64, steps: u64, f: F) -> f64 {
199    if steps == 0 {
200        return y0;
201    }
202    // First step: BDF1 to seed the two-point history.
203    let mut y_prev = y0;
204    let mut y_curr = bdf1_step(t0, y0, h, &f);
205    let mut t = t0 + h;
206    for _ in 1..steps {
207        let y_next = bdf2_step(t, y_curr, y_prev, h, &f);
208        y_prev = y_curr;
209        y_curr = y_next;
210        t += h;
211    }
212    y_curr
213}
214
215// ── 3. Dense output (continuous extension) ──────────────────────────────────────
216
217/// Cubic-Hermite dense output: the state at `θ ∈ [0,1]` within a step from
218/// `(t₀,y0)` to `(t₁,y1)` where `f0=f(t₀,y0)`, `f1=f(t₁,y1)` and `h=t₁−t₀`, WITHOUT
219/// re-evaluating the derivative. Exact for cubic trajectories; matches the endpoints
220/// and their slopes (`θ=0 → y0`, `θ=1 → y1`).
221pub fn hermite_dense_output(y0: f64, f0: f64, y1: f64, f1: f64, h: f64, theta: f64) -> f64 {
222    let t = theta;
223    let t2 = t * t;
224    let t3 = t2 * t;
225    let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
226    let h10 = t3 - 2.0 * t2 + t;
227    let h01 = -2.0 * t3 + 3.0 * t2;
228    let h11 = t3 - t2;
229    h00 * y0 + h10 * h * f0 + h01 * y1 + h11 * h * f1
230}
231
232// ── 4. Forward sensitivity analysis (∂y/∂y₀) ────────────────────────────────────
233
234/// Result of [`integrate_with_sensitivity`].
235#[derive(Debug, Clone, Copy, PartialEq)]
236pub struct SensitivityResult {
237    /// The integrated state `y(t)`.
238    pub y: f64,
239    /// The forward sensitivity `s(t) = ∂y(t)/∂y₀`.
240    pub sensitivity: f64,
241}
242
243/// Integrate `dy/dt = f(t,y)` together with the variational equation
244/// `ds/dt = f_y(t,y)·s`, `s(0)=1`, by RK4 on the augmented `(y, s)` system. Returns
245/// `y(t)` and `∂y/∂y₀` at `t₀ + steps·h`. `f_y` is estimated by central differences.
246/// Zero-heap.
247pub fn integrate_with_sensitivity<F: Fn(f64, f64) -> f64>(
248    t0: f64,
249    y0: f64,
250    h: f64,
251    steps: u64,
252    f: F,
253) -> SensitivityResult {
254    let mut t = t0;
255    let mut y = y0;
256    let mut s = 1.0f64; // ∂y0/∂y0 = 1
257
258    // Augmented derivative: (dy, ds) = (f(t,y), f_y(t,y)·s).
259    let deriv = |t: f64, y: f64, s: f64| -> (f64, f64) { (f(t, y), dfdy_fd(&f, t, y) * s) };
260
261    for _ in 0..steps {
262        let (k1y, k1s) = deriv(t, y, s);
263        let (k2y, k2s) = deriv(t + 0.5 * h, y + 0.5 * h * k1y, s + 0.5 * h * k1s);
264        let (k3y, k3s) = deriv(t + 0.5 * h, y + 0.5 * h * k2y, s + 0.5 * h * k2s);
265        let (k4y, k4s) = deriv(t + h, y + h * k3y, s + h * k3s);
266        y += (h / 6.0) * (k1y + 2.0 * k2y + 2.0 * k3y + k4y);
267        s += (h / 6.0) * (k1s + 2.0 * k2s + 2.0 * k3s + k4s);
268        t += h;
269    }
270
271    SensitivityResult { y, sensitivity: s }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    // Harmonic oscillator H = p²/2 + q²/2 (unit mass, ω=1): force(q) = -q, T'(p)=p.
279    // Exact solution: q(t)=cos t (from q0=1,p0=0), energy = 1/2 conserved.
280    fn ho_force(q: f64) -> f64 {
281        -q
282    }
283    fn ho_kin(p: f64) -> f64 {
284        p
285    }
286    fn ho_energy(q: f64, p: f64) -> f64 {
287        0.5 * p * p + 0.5 * q * q
288    }
289
290    #[test]
291    fn symplectic_methods_conserve_energy_over_many_periods() {
292        // 200 periods (2π each) at a coarse step — a non-symplectic method would drift.
293        let h = 0.01;
294        let steps = (200.0 * 2.0 * std::f64::consts::PI / h) as u64;
295        for method in [
296            SymplecticMethod::Verlet,
297            SymplecticMethod::Ruth3,
298            SymplecticMethod::Yoshida4,
299        ] {
300            let r = integrate_symplectic(1.0, 0.0, h, steps, ho_force, ho_kin, ho_energy, method);
301            // Bounded energy drift (no secular growth): well under 1% of E₀=0.5.
302            assert!(
303                r.max_energy_drift < 5e-3,
304                "{method:?} energy drift {} too large",
305                r.max_energy_drift
306            );
307        }
308    }
309
310    #[test]
311    fn symplectic_convergence_orders_match_labels() {
312        // The rigorous order check: the empirical convergence rate
313        // p ≈ log2(err(h)/err(h/2)) must match each method's label (2/3/4). (At a
314        // *fixed* h the error *constants* differ, so a higher-order method need not
315        // have smaller error — only the rate as h→0 is guaranteed.) The position error
316        // is measured at a GENERIC time t=1.0 (NOT a period/quarter multiple — at an
317        // extremum of cos the leading phase error would square and mis-report the
318        // order). For q0=1,p0=0,ω=1 the exact solution is q(t)=cos t.
319        let t_end = 1.0f64;
320        let exact = t_end.cos();
321        let err_at = |m, h: f64| {
322            let steps = (t_end / h).round().max(1.0) as u64;
323            let h = t_end / steps as f64; // land exactly on t = 1.0
324            let r = integrate_symplectic(1.0, 0.0, h, steps, ho_force, ho_kin, ho_energy, m);
325            (r.q - exact).abs()
326        };
327        let order = |m| {
328            let e1 = err_at(m, 0.04);
329            let e2 = err_at(m, 0.02);
330            (e1 / e2).log2()
331        };
332        let o2 = order(SymplecticMethod::Verlet);
333        let o3 = order(SymplecticMethod::Ruth3);
334        let o4 = order(SymplecticMethod::Yoshida4);
335        assert!(
336            (o2 - 2.0).abs() < 0.5,
337            "Verlet should be ~2nd order, got {o2:.2}"
338        );
339        assert!(
340            (o3 - 3.0).abs() < 0.6,
341            "Ruth3 should be ~3rd order, got {o3:.2}"
342        );
343        assert!(
344            (o4 - 4.0).abs() < 0.8,
345            "Yoshida4 should be ~4th order, got {o4:.2}"
346        );
347    }
348
349    #[test]
350    fn bdf_is_stable_on_a_stiff_equation() {
351        // y' = -1000 y, y0 = 1. Exact y(0.5)=e^{-500}≈0 (tiny). Backward Euler / BDF2
352        // are L-stable: with a big step h=0.1 they decay monotonically to ~0; explicit
353        // Euler with the same h would explode (|1 - 1000·0.1| = 99 per step).
354        let f = |_t: f64, y: f64| -10.0 * y; // moderately stiff for a quick, exact check
355                                             // Backward Euler one step, h=0.5: y1 = y0/(1+5) = 1/6.
356        let y1 = bdf1_step(0.0, 1.0, 0.5, f);
357        assert!(
358            (y1 - 1.0 / 6.0).abs() < 1e-9,
359            "BDF1 implicit-Euler value, got {y1}"
360        );
361
362        // Strongly stiff, large step: must stay bounded in [0,1] and shrink, never blow up.
363        let stiff = |_t: f64, y: f64| -1000.0 * y;
364        let yf = integrate_bdf(0.0, 1.0, 0.1, 5, stiff);
365        assert!(
366            yf.abs() < 1e-2 && yf.is_finite(),
367            "BDF2 stiff result blew up: {yf}"
368        );
369        assert!(
370            yf >= 0.0,
371            "L-stable decay should not overshoot below 0: {yf}"
372        );
373    }
374
375    #[test]
376    fn bdf2_matches_linear_decay_accurately() {
377        // y' = -y, y0 = 1 → y(1) = e^{-1} ≈ 0.367879. BDF2 with small h is 2nd-order.
378        let f = |_t: f64, y: f64| -y;
379        let yf = integrate_bdf(0.0, 1.0, 1e-3, 1000, f);
380        let exact = (-1.0f64).exp();
381        assert!((yf - exact).abs() < 1e-5, "BDF2 got {yf}, exact {exact}");
382    }
383
384    #[test]
385    fn dense_output_is_exact_for_a_cubic() {
386        // y(t) = 1 + 2t + 3t² + 4t³ on [0,1]; f = y' = 2 + 6t + 12t².
387        let y = |t: f64| 1.0 + 2.0 * t + 3.0 * t * t + 4.0 * t * t * t;
388        let f = |t: f64| 2.0 + 6.0 * t + 12.0 * t * t;
389        let (t0, t1) = (0.0, 1.0);
390        let h = t1 - t0;
391        for &theta in &[0.0, 0.25, 0.5, 0.75, 1.0] {
392            let interp = hermite_dense_output(y(t0), f(t0), y(t1), f(t1), h, theta);
393            let exact = y(t0 + theta * h);
394            assert!(
395                (interp - exact).abs() < 1e-12,
396                "θ={theta}: {interp} vs {exact}"
397            );
398        }
399    }
400
401    #[test]
402    fn forward_sensitivity_matches_analytic_exponential() {
403        // y' = -λy, y(t) = y0·e^{-λt}, so ∂y/∂y0 = e^{-λt}. With λ=2, t=1:
404        // s(1) should be e^{-2} and y(1) should be 0.5·e^{-2} (y0 = 0.5).
405        let lambda = 2.0;
406        let f = move |_t: f64, y: f64| -lambda * y;
407        let r = integrate_with_sensitivity(0.0, 0.5, 1e-3, 1000, f);
408        let exp_m2 = (-2.0f64).exp();
409        assert!(
410            (r.sensitivity - exp_m2).abs() < 1e-5,
411            "∂y/∂y0 got {}, want {exp_m2}",
412            r.sensitivity
413        );
414        assert!(
415            (r.y - 0.5 * exp_m2).abs() < 1e-6,
416            "y got {}, want {}",
417            r.y,
418            0.5 * exp_m2
419        );
420    }
421}