Skip to main content

numra_ode/
sensitivity.rs

1//! Forward sensitivity analysis for parameterised ODE systems.
2//!
3//! Given an ODE system parameterised by a vector $p \in \mathbb{R}^{N_s}$,
4//!
5//! ```text
6//! dy/dt = f(t, y, p),    y(t_0) = y_0(p),
7//! ```
8//!
9//! the *forward sensitivity matrix* `S(t) = ∂y(t) / ∂p ∈ ℝ^{N × N_s}` satisfies
10//! the variational equations
11//!
12//! ```text
13//! dS/dt = J_y(t, y, p) · S + J_p(t, y, p),    S(t_0) = ∂y_0/∂p
14//! ```
15//!
16//! where `J_y = ∂f/∂y ∈ ℝ^{N×N}` and `J_p = ∂f/∂p ∈ ℝ^{N×N_s}`.
17//!
18//! Numra integrates the augmented state `z = [y; vec(S)] ∈ ℝ^{N(1+N_s)}` with
19//! the user's chosen [`crate::Solver`] (DoPri5, Tsit5, Vern\*, Radau5, BDF,
20//! Esdirk\*). The augmented Jacobian is `block_diag(J_y, …, J_y)` —
21//! the standard CVODES *simultaneous-corrector* formulation.
22//!
23//! # Layout conventions
24//!
25//! Numra mixes row-major and column-major flattening so that each Jacobian's
26//! "natural" axis is contiguous. Implementors of [`ParametricOdeSystem`] must
27//! follow these rules exactly:
28//!
29//! | Quantity                        | Layout         | Index map                         |
30//! | ------------------------------- | -------------- | --------------------------------- |
31//! | state `y`                       | flat, length `N`           | `y[i]`                |
32//! | state Jacobian `J_y`            | row-major, `N × N`         | `jy[i*N + j] = ∂f_i/∂y_j` |
33//! | parameter Jacobian `J_p`        | column-major, `N × N_s`    | `jp[k*N + i] = ∂f_i/∂p_k` |
34//! | sensitivity `S`                 | column-major, `N × N_s`    | `s[k*N + i] = ∂y_i/∂p_k`  |
35//! | augmented state `z`             | `[y; vec(S)]`              | `z[N + k*N + i] = S_{i,k}` |
36//! | initial sensitivity `∂y_0/∂p`   | column-major, `N × N_s`    | `s0[k*N + i] = ∂y_{0,i}/∂p_k` |
37//!
38//! Choosing column-major for `J_p` and `S` matches CVODES indexing, makes the
39//! per-parameter sub-vector `S_{:,k}` contiguous (and free to slice via
40//! [`SensitivityResult::sensitivity_for_param`]), and improves cache locality
41//! in the inner loop `Ṡ_{:,k} = J_y · S_{:,k} + J_p_{:,k}`. `J_y` stays
42//! row-major because state-row access is the standard `OdeSystem::jacobian`
43//! convention used by every implicit solver in this crate.
44//!
45//! # Worked example: 2-state, 2-parameter linear ODE
46//!
47//! Consider the system
48//!
49//! ```text
50//! ẏ_0 = -p_0 · y_0 + p_1 · y_1
51//! ẏ_1 =          - p_1 · y_1
52//! ```
53//!
54//! Then
55//!
56//! ```text
57//! J_y = [ -p_0    p_1 ]      J_p = [ -y_0    y_1 ]
58//!       [   0    -p_1 ]            [   0    -y_1 ]
59//! ```
60//!
61//! With `N = N_s = 2`, `J_y` is row-major and `J_p` is column-major.
62//! Implementing the trait by hand:
63//!
64//! ```
65//! use numra_ode::sensitivity::ParametricOdeSystem;
66//!
67//! struct Lin2 { p: [f64; 2] }
68//!
69//! impl ParametricOdeSystem<f64> for Lin2 {
70//!     fn n_states(&self) -> usize { 2 }
71//!     fn n_params(&self) -> usize { 2 }
72//!     fn params(&self) -> &[f64] { &self.p }
73//!
74//!     fn rhs_with_params(&self, _t: f64, y: &[f64], p: &[f64], dy: &mut [f64]) {
75//!         dy[0] = -p[0] * y[0] + p[1] * y[1];
76//!         dy[1] =                -p[1] * y[1];
77//!     }
78//!
79//!     fn jacobian_y(&self, _t: f64, _y: &[f64], jy: &mut [f64]) {
80//!         // Row-major (N × N): jy[i*N + j] = ∂f_i/∂y_j
81//!         jy[0*2 + 0] = -self.p[0];   // ∂f_0/∂y_0
82//!         jy[0*2 + 1] =  self.p[1];   // ∂f_0/∂y_1
83//!         jy[1*2 + 0] =  0.0;         // ∂f_1/∂y_0
84//!         jy[1*2 + 1] = -self.p[1];   // ∂f_1/∂y_1
85//!     }
86//!
87//!     fn jacobian_p(&self, _t: f64, y: &[f64], jp: &mut [f64]) {
88//!         // Column-major (N × N_s): jp[k*N + i] = ∂f_i/∂p_k
89//!         jp[0*2 + 0] = -y[0];        // ∂f_0/∂p_0
90//!         jp[0*2 + 1] =  0.0;         // ∂f_1/∂p_0
91//!         jp[1*2 + 0] =  y[1];        // ∂f_0/∂p_1
92//!         jp[1*2 + 1] = -y[1];        // ∂f_1/∂p_1
93//!     }
94//!
95//!     // Flag analytical so AugmentedSystem honors the overrides above
96//!     // instead of falling through to its inline-FD fast path.
97//!     fn has_analytical_jacobian_y(&self) -> bool { true }
98//!     fn has_analytical_jacobian_p(&self) -> bool { true }
99//! }
100//!
101//! let sys = Lin2 { p: [1.0, 0.5] };
102//! assert_eq!(sys.n_states(), 2);
103//! assert_eq!(sys.n_params(), 2);
104//! ```
105//!
106//! Note the asymmetry: `J_y[0*2 + 1]` is row 0, column 1 of `J_y` (state
107//! Jacobian, row-major), while `J_p[1*2 + 0]` is row 0, column 1 of `J_p`
108//! (parameter Jacobian, column-major). They look indexed the same way in
109//! Rust syntax but address different mathematical entries by design.
110//!
111//! # Send / Sync
112//!
113//! [`ParametricOdeSystem`] does **not** require `Send + Sync`, matching the
114//! [`crate::OdeSystem`] precedent. Add the bounds at the call site
115//! (`where Sys: ParametricOdeSystem<S> + Send + Sync`) when sharing the
116//! system across threads — for instance in parallel Monte Carlo. Tightening
117//! a trait bound is a breaking change; loosening it is not, so the default
118//! is permissive.
119//!
120//! # Performance note (v1)
121//!
122//! Numra's v1 ships *correct* forward sensitivity through every solver, with
123//! analytical `J_y` and `J_p` overrides replacing FD on the dominant cost
124//! path. The block-diagonal structure of the augmented Jacobian — which
125//! would let implicit solvers reuse a single LU of `M = (1/γh)·I_N - J_y`
126//! across all `N_s + 1` sub-systems — is **not yet exploited**: Radau5 and
127//! BDF currently perform dense LU on the full `N(N_s+1) × N(N_s+1)` matrix.
128//! Block-aware factorisation is tracked as a named follow-up (see
129//! `docs/internal-followups.md`).
130//!
131//! Author: Moussa Leblouba
132//! Date: 4 February 2026
133//! Modified: 6 May 2026
134
135use numra_core::Scalar;
136
137use crate::error::SolverError;
138use crate::problem::{OdeProblem, OdeSystem};
139use crate::solver::{Solver, SolverOptions, SolverStats};
140
141/// An ODE system parameterised by a parameter vector `p`.
142///
143/// Implementors expose dimensions, the nominal parameter vector, the
144/// parameterised right-hand side, and optionally analytical Jacobians.
145/// Forward finite differences are provided as defaults for `jacobian_y` and
146/// `jacobian_p`, so a minimal implementation supplies only `n_states`,
147/// `n_params`, `params`, and `rhs_with_params`.
148///
149/// See the [module-level documentation](self) for the layout conventions
150/// (`J_y` row-major, `J_p` and `S` column-major) and a worked 2-state,
151/// 2-parameter example.
152pub trait ParametricOdeSystem<S: Scalar> {
153    /// Number of state variables `N` (the dimension of `y`).
154    fn n_states(&self) -> usize;
155
156    /// Number of parameters `N_s` (the dimension of `p`).
157    fn n_params(&self) -> usize;
158
159    /// Nominal parameter vector. Length must equal [`Self::n_params`].
160    fn params(&self) -> &[S];
161
162    /// Evaluate `f(t, y, p) -> dy/dt` for an arbitrary parameter vector `p`.
163    ///
164    /// This is the primitive form: it lets the FD-default Jacobians perturb
165    /// `p` without mutating `self`.
166    fn rhs_with_params(&self, t: S, y: &[S], p: &[S], dydt: &mut [S]);
167
168    /// Convenience: evaluate the RHS at the system's nominal parameters.
169    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
170        self.rhs_with_params(t, y, self.params(), dydt);
171    }
172
173    /// Fill the state Jacobian `J_y[i,j] = ∂f_i/∂y_j` in **row-major** order
174    /// (`jac[i*N + j]`, length `N²`).
175    ///
176    /// Default: forward finite differences via [`Self::rhs_with_params`].
177    /// Override to supply an analytical Jacobian — required for performance
178    /// on stiff problems with implicit solvers.
179    fn jacobian_y(&self, t: S, y: &[S], jac: &mut [S]) {
180        let n = self.n_states();
181        let p = self.params();
182        let h_factor = S::EPSILON.sqrt();
183
184        let mut f0 = vec![S::ZERO; n];
185        let mut f1 = vec![S::ZERO; n];
186        let mut y_pert = y.to_vec();
187
188        self.rhs_with_params(t, y, p, &mut f0);
189
190        for j in 0..n {
191            let yj = y_pert[j];
192            let h = h_factor * (S::ONE + yj.abs());
193            y_pert[j] = yj + h;
194            self.rhs_with_params(t, &y_pert, p, &mut f1);
195            y_pert[j] = yj;
196            for i in 0..n {
197                jac[i * n + j] = (f1[i] - f0[i]) / h;
198            }
199        }
200    }
201
202    /// Fill the parameter Jacobian `J_p[i,k] = ∂f_i/∂p_k` in **column-major**
203    /// order (`jp[k*N + i]`, length `N · N_s`).
204    ///
205    /// Default: forward finite differences. Override for performance.
206    fn jacobian_p(&self, t: S, y: &[S], jp: &mut [S]) {
207        let n = self.n_states();
208        let np = self.n_params();
209        let p_nominal = self.params();
210        let h_factor = S::EPSILON.sqrt();
211
212        let mut f0 = vec![S::ZERO; n];
213        let mut f1 = vec![S::ZERO; n];
214        let mut p_pert = p_nominal.to_vec();
215
216        self.rhs_with_params(t, y, p_nominal, &mut f0);
217
218        for k in 0..np {
219            let pk = p_pert[k];
220            let h = h_factor * (S::ONE + pk.abs());
221            p_pert[k] = pk + h;
222            self.rhs_with_params(t, y, &p_pert, &mut f1);
223            p_pert[k] = pk;
224            for i in 0..n {
225                jp[k * n + i] = (f1[i] - f0[i]) / h;
226            }
227        }
228    }
229
230    /// Initial sensitivity `∂y_0/∂p`, column-major (`s0[k*N + i]`, length
231    /// `N · N_s`).
232    ///
233    /// Defaults to zero, which is correct when `y_0` does not depend on `p`.
234    /// Override only if `y_0 = y_0(p)`.
235    fn initial_sensitivity(&self, _y0: &[S], s0: &mut [S]) {
236        for s in s0.iter_mut() {
237            *s = S::ZERO;
238        }
239    }
240
241    /// Returns `true` iff [`Self::jacobian_y`] has been overridden with an
242    /// analytical implementation. Default: `false`.
243    ///
244    /// `AugmentedSystem` checks this flag inside its hot path. When `false`
245    /// (the default), it inlines its own forward-FD using reused scratch
246    /// buffers — bypassing both the trait default and any override.
247    /// When `true`, it calls `system.jacobian_y(...)` directly so the user's
248    /// analytical override is honored.
249    ///
250    /// This is the pattern used by CVODES (`set_jacobian_user_supplied`):
251    /// flagging analytical Jacobians lets the augmented-system path skip
252    /// FD scratch allocation in the common FD case while still respecting
253    /// analytical overrides in the stiff-problem case where they materially
254    /// matter. **If you override `jacobian_y`, also override this method to
255    /// return `true`** — otherwise your override is silently bypassed when
256    /// running through `solve_forward_sensitivity`.
257    ///
258    /// # Debug-build safety net
259    ///
260    /// To catch the silent-misconfiguration case (override the method,
261    /// forget the flag), `AugmentedSystem` runs a one-time consistency
262    /// check on the first RHS call: it evaluates [`Self::jacobian_y`]
263    /// (which dispatches to the user's override or the FD default) and
264    /// compares against the inline FD result. If they disagree by more
265    /// than a Frobenius-norm relative threshold of `1e-3`, the system
266    /// panics in debug builds with a message naming the missing flag
267    /// override. Release builds skip the check entirely (zero overhead).
268    /// This is a safety net, not a contract — fixing the panic by
269    /// returning `true` here is the canonical resolution.
270    fn has_analytical_jacobian_y(&self) -> bool {
271        false
272    }
273
274    /// Returns `true` iff [`Self::jacobian_p`] has been overridden with an
275    /// analytical implementation. Default: `false`. See
276    /// [`Self::has_analytical_jacobian_y`] for the rationale, contract,
277    /// and debug-build consistency check.
278    fn has_analytical_jacobian_p(&self) -> bool {
279        false
280    }
281}
282
283/// Wraps a [`ParametricOdeSystem`] as an [`OdeSystem`] over the augmented
284/// state `z = [y; vec(S)]` (column-major sensitivity flattening).
285///
286/// The augmented dimension is `N · (1 + N_s)`. The augmented Jacobian is
287/// `block_diag(J_y, J_y, …, J_y)` (CVODES simultaneous-corrector form).
288pub struct AugmentedSystem<S: Scalar, Sys: ParametricOdeSystem<S>> {
289    /// The underlying parameterised system.
290    pub system: Sys,
291    jy_scratch: std::cell::RefCell<Vec<S>>,
292    jp_scratch: std::cell::RefCell<Vec<S>>,
293    // Reusable FD scratch lifted out of the trait's per-call allocations.
294    // Touched only on the FD path (when has_analytical_jacobian_{y,p}
295    // returns false). Each RefCell is borrowed in isolation; no overlap.
296    fd_f0: std::cell::RefCell<Vec<S>>,
297    fd_f1: std::cell::RefCell<Vec<S>>,
298    fd_y_pert: std::cell::RefCell<Vec<S>>,
299    fd_p_pert: std::cell::RefCell<Vec<S>>,
300    // Debug-only: tracks whether the first-call analytical-vs-FD
301    // consistency check has run. See `check_jacobian_flags`.
302    #[cfg(debug_assertions)]
303    flag_check_done: std::cell::Cell<bool>,
304}
305
306impl<S: Scalar, Sys: ParametricOdeSystem<S>> AugmentedSystem<S, Sys> {
307    /// Wrap a [`ParametricOdeSystem`] into an `OdeSystem`-shaped augmented
308    /// system suitable for any [`crate::Solver`].
309    pub fn new(system: Sys) -> Self {
310        let n = system.n_states();
311        let np = system.n_params();
312        Self {
313            system,
314            jy_scratch: std::cell::RefCell::new(vec![S::ZERO; n * n]),
315            jp_scratch: std::cell::RefCell::new(vec![S::ZERO; n * np]),
316            fd_f0: std::cell::RefCell::new(vec![S::ZERO; n]),
317            fd_f1: std::cell::RefCell::new(vec![S::ZERO; n]),
318            fd_y_pert: std::cell::RefCell::new(vec![S::ZERO; n]),
319            fd_p_pert: std::cell::RefCell::new(vec![S::ZERO; np]),
320            #[cfg(debug_assertions)]
321            flag_check_done: std::cell::Cell::new(false),
322        }
323    }
324
325    /// Debug-build consistency check for the analytical-Jacobian flags.
326    ///
327    /// Catches the silent-misconfiguration class of bug where a user
328    /// implements `jacobian_y` (or `jacobian_p`) analytically but forgets
329    /// to override `has_analytical_jacobian_y` (or `_p`) — in which case
330    /// the augmented hot path bypasses the analytical override entirely
331    /// and runs FD instead. The user sees "Numra is slow on my problem"
332    /// with no diagnostic path.
333    ///
334    /// On the first `rhs` call (and only then), if a flag returns `false`,
335    /// we evaluate `system.jacobian_*` (which dispatches to the user's
336    /// override or the trait's FD default) and compare against the inline
337    /// FD result. If the user did not override the method, both calls
338    /// compute FD and agree to floating-point noise. If the user did
339    /// override with analytical, the two will typically disagree well
340    /// beyond the relative threshold — and we panic with a clear message.
341    ///
342    /// The threshold is `1e-3` relative (Frobenius). FD is only accurate
343    /// to `~sqrt(eps_mach) ≈ 1.5e-8`, so 1e-3 is loose enough to absorb
344    /// honest FD-vs-analytical disagreement at the initial state without
345    /// false positives, while still catching the "forgot the flag"
346    /// scenario where the analytical and FD results differ by O(1).
347    ///
348    /// Wrapped in `#[cfg(debug_assertions)]` — release builds pay zero.
349    #[cfg(debug_assertions)]
350    fn check_jacobian_flags(&self, t: S, y: &[S]) {
351        let n = self.system.n_states();
352        let np = self.system.n_params();
353        let threshold = S::from_f64(1e-3);
354
355        if !self.system.has_analytical_jacobian_y() {
356            let mut user_jy = vec![S::ZERO; n * n];
357            let mut fd_jy = vec![S::ZERO; n * n];
358            self.system.jacobian_y(t, y, &mut user_jy);
359            self.fd_jacobian_y_inline(t, y, &mut fd_jy);
360            if jacobians_differ_significantly(&user_jy, &fd_jy, threshold) {
361                panic!(
362                    "ParametricOdeSystem implements `jacobian_y` analytically \
363                     but `has_analytical_jacobian_y()` returns `false`; the \
364                     analytical implementation is being ignored and `AugmentedSystem` \
365                     is running finite differences instead. Override \
366                     `has_analytical_jacobian_y` to return `true`. \
367                     (This check is debug-build only; release builds will \
368                     silently use FD.)"
369                );
370            }
371        }
372
373        if !self.system.has_analytical_jacobian_p() && np > 0 {
374            let mut user_jp = vec![S::ZERO; n * np];
375            let mut fd_jp = vec![S::ZERO; n * np];
376            self.system.jacobian_p(t, y, &mut user_jp);
377            self.fd_jacobian_p_inline(t, y, &mut fd_jp);
378            if jacobians_differ_significantly(&user_jp, &fd_jp, threshold) {
379                panic!(
380                    "ParametricOdeSystem implements `jacobian_p` analytically \
381                     but `has_analytical_jacobian_p()` returns `false`; the \
382                     analytical implementation is being ignored and `AugmentedSystem` \
383                     is running finite differences instead. Override \
384                     `has_analytical_jacobian_p` to return `true`. \
385                     (This check is debug-build only; release builds will \
386                     silently use FD.)"
387                );
388            }
389        }
390    }
391
392    /// Forward-FD `J_y` using AugmentedSystem-local scratch buffers. Only
393    /// invoked when `system.has_analytical_jacobian_y()` is `false`.
394    fn fd_jacobian_y_inline(&self, t: S, y: &[S], jy: &mut [S]) {
395        let n = self.system.n_states();
396        let p = self.system.params();
397        let h_factor = S::EPSILON.sqrt();
398        let mut f0 = self.fd_f0.borrow_mut();
399        let mut f1 = self.fd_f1.borrow_mut();
400        let mut y_pert = self.fd_y_pert.borrow_mut();
401
402        self.system.rhs_with_params(t, y, p, &mut f0);
403        y_pert.copy_from_slice(y);
404        for j in 0..n {
405            let yj = y_pert[j];
406            let h = h_factor * (S::ONE + yj.abs());
407            y_pert[j] = yj + h;
408            self.system.rhs_with_params(t, &y_pert, p, &mut f1);
409            y_pert[j] = yj;
410            for i in 0..n {
411                jy[i * n + j] = (f1[i] - f0[i]) / h;
412            }
413        }
414    }
415
416    /// Forward-FD `J_p` using AugmentedSystem-local scratch buffers. Only
417    /// invoked when `system.has_analytical_jacobian_p()` is `false`.
418    fn fd_jacobian_p_inline(&self, t: S, y: &[S], jp: &mut [S]) {
419        let n = self.system.n_states();
420        let np = self.system.n_params();
421        let p_nominal = self.system.params();
422        let h_factor = S::EPSILON.sqrt();
423        let mut f0 = self.fd_f0.borrow_mut();
424        let mut f1 = self.fd_f1.borrow_mut();
425        let mut p_pert = self.fd_p_pert.borrow_mut();
426
427        self.system.rhs_with_params(t, y, p_nominal, &mut f0);
428        p_pert.copy_from_slice(p_nominal);
429        for k in 0..np {
430            let pk = p_pert[k];
431            let h = h_factor * (S::ONE + pk.abs());
432            p_pert[k] = pk + h;
433            self.system.rhs_with_params(t, y, &p_pert, &mut f1);
434            p_pert[k] = pk;
435            for i in 0..n {
436                jp[k * n + i] = (f1[i] - f0[i]) / h;
437            }
438        }
439    }
440
441    /// Total augmented dimension `N · (1 + N_s)`.
442    pub fn augmented_dim(&self) -> usize {
443        self.system.n_states() * (1 + self.system.n_params())
444    }
445
446    /// Build the initial augmented state `[y_0; vec(∂y_0/∂p)]`.
447    pub fn initial_augmented(&self, y0: &[S]) -> Vec<S> {
448        let n = self.system.n_states();
449        let np = self.system.n_params();
450
451        let mut z0 = Vec::with_capacity(n * (1 + np));
452        z0.extend_from_slice(y0);
453
454        let mut s0 = vec![S::ZERO; n * np];
455        self.system.initial_sensitivity(y0, &mut s0);
456        z0.extend_from_slice(&s0);
457        z0
458    }
459}
460
461/// Frobenius-norm relative comparison: returns `true` if
462/// `||a - b||_F / max(||b||_F, 1) > threshold`. Used by the debug-build
463/// analytical-vs-FD Jacobian flag check.
464#[cfg(debug_assertions)]
465fn jacobians_differ_significantly<S: Scalar>(a: &[S], b: &[S], threshold: S) -> bool {
466    debug_assert_eq!(a.len(), b.len());
467    let mut diff_sq = S::ZERO;
468    let mut b_sq = S::ZERO;
469    for i in 0..a.len() {
470        let d = a[i] - b[i];
471        diff_sq = diff_sq + d * d;
472        b_sq = b_sq + b[i] * b[i];
473    }
474    let denom = b_sq.sqrt().max(S::ONE);
475    let rel = diff_sq.sqrt() / denom;
476    rel > threshold
477}
478
479impl<S: Scalar, Sys: ParametricOdeSystem<S>> OdeSystem<S> for AugmentedSystem<S, Sys> {
480    fn dim(&self) -> usize {
481        self.augmented_dim()
482    }
483
484    /// Augmented RHS: `dy/dt = f(t,y,p)`, `dS_{:,k}/dt = J_y · S_{:,k} + J_p_{:,k}`.
485    fn rhs(&self, t: S, z: &[S], dz: &mut [S]) {
486        let n = self.system.n_states();
487        let np = self.system.n_params();
488        let y = &z[..n];
489
490        // Debug-only one-time consistency check: analytical-Jacobian flags
491        // vs the user's actual `jacobian_*` override. See
492        // `check_jacobian_flags` for rationale.
493        #[cfg(debug_assertions)]
494        if !self.flag_check_done.get() {
495            self.flag_check_done.set(true);
496            self.check_jacobian_flags(t, y);
497        }
498
499        // (a) original dynamics.
500        self.system.rhs(t, y, &mut dz[..n]);
501
502        // (b) Jacobians at the current state. Analytical when the system
503        // flags it; otherwise inline FD using local scratch buffers (lifted
504        // out of the trait defaults so the integration's hot path doesn't
505        // pay per-call allocation).
506        let mut jy = self.jy_scratch.borrow_mut();
507        let mut jp = self.jp_scratch.borrow_mut();
508        if self.system.has_analytical_jacobian_y() {
509            self.system.jacobian_y(t, y, &mut jy);
510        } else {
511            drop(jy);
512            self.fd_jacobian_y_inline(t, y, &mut self.jy_scratch.borrow_mut());
513            jy = self.jy_scratch.borrow_mut();
514        }
515        if self.system.has_analytical_jacobian_p() {
516            self.system.jacobian_p(t, y, &mut jp);
517        } else {
518            drop(jp);
519            self.fd_jacobian_p_inline(t, y, &mut self.jp_scratch.borrow_mut());
520            jp = self.jp_scratch.borrow_mut();
521        }
522
523        // (c) per-parameter sensitivity column: dS_{:,k}/dt = J_y · S_{:,k} + J_p_{:,k}.
524        // Sensitivity column-major: S_{i,k} lives at z[n + k*n + i].
525        for k in 0..np {
526            for i in 0..n {
527                let mut acc = S::ZERO;
528                // J_y row-major dot S_{:,k} (contiguous slice of z).
529                for j in 0..n {
530                    acc = acc + jy[i * n + j] * z[n + k * n + j];
531                }
532                acc = acc + jp[k * n + i];
533                dz[n + k * n + i] = acc;
534            }
535        }
536    }
537
538    /// Augmented Jacobian, row-major over the full `N(1+N_s) × N(1+N_s)` block.
539    ///
540    /// Fills the `N_s + 1` diagonal blocks with `J_y`. Off-diagonal blocks
541    /// (which would carry `∂(J_y · S_{:,k})/∂y` second-derivative coupling)
542    /// are zero — the standard CVODES *simultaneous-corrector* approximation.
543    /// Implicit solvers in v1 perform dense LU on the full block; the
544    /// block-diagonal-aware fast path is a tracked follow-up.
545    fn jacobian(&self, t: S, z: &[S], jac: &mut [S]) {
546        let n = self.system.n_states();
547        let np = self.system.n_params();
548        let dim = n * (1 + np);
549        let y = &z[..n];
550
551        // Zero the entire augmented matrix first.
552        for slot in jac.iter_mut() {
553            *slot = S::ZERO;
554        }
555
556        let mut jy = self.jy_scratch.borrow_mut();
557        self.system.jacobian_y(t, y, &mut jy);
558
559        // Place J_y on each of the n_s + 1 diagonal blocks.
560        for block in 0..(1 + np) {
561            let row0 = block * n;
562            let col0 = block * n;
563            for i in 0..n {
564                for j in 0..n {
565                    jac[(row0 + i) * dim + (col0 + j)] = jy[i * n + j];
566                }
567            }
568        }
569    }
570}
571
572/// Forwarding impl: a reference to a parametric system is itself a parametric
573/// system. Lets [`solve_forward_sensitivity`] accept `&Sys` without taking
574/// ownership of the user's data.
575impl<S: Scalar, T: ParametricOdeSystem<S>> ParametricOdeSystem<S> for &T {
576    fn n_states(&self) -> usize {
577        (*self).n_states()
578    }
579    fn n_params(&self) -> usize {
580        (*self).n_params()
581    }
582    fn params(&self) -> &[S] {
583        (*self).params()
584    }
585    fn rhs_with_params(&self, t: S, y: &[S], p: &[S], dydt: &mut [S]) {
586        (*self).rhs_with_params(t, y, p, dydt)
587    }
588    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
589        (*self).rhs(t, y, dydt)
590    }
591    fn jacobian_y(&self, t: S, y: &[S], jac: &mut [S]) {
592        (*self).jacobian_y(t, y, jac)
593    }
594    fn jacobian_p(&self, t: S, y: &[S], jp: &mut [S]) {
595        (*self).jacobian_p(t, y, jp)
596    }
597    fn initial_sensitivity(&self, y0: &[S], s0: &mut [S]) {
598        (*self).initial_sensitivity(y0, s0)
599    }
600    fn has_analytical_jacobian_y(&self) -> bool {
601        (*self).has_analytical_jacobian_y()
602    }
603    fn has_analytical_jacobian_p(&self) -> bool {
604        (*self).has_analytical_jacobian_p()
605    }
606}
607
608/// Result of an ODE forward-sensitivity solve.
609///
610/// Layout matches the conventions in the [module-level docs](self):
611/// the state `y` is row-major over time; the sensitivity is row-major over
612/// time, column-major within each per-time block.
613#[derive(Clone, Debug)]
614pub struct SensitivityResult<S: Scalar> {
615    /// Output time points (length `n_times`).
616    pub t: Vec<S>,
617    /// State trajectory: `y[i*N + j] = y_j(t_i)`, length `n_times * N`.
618    pub y: Vec<S>,
619    /// Sensitivities: per-time block of length `N · N_s`, column-major
620    /// within the block, so
621    /// `sensitivity[i * (N*N_s) + k*N + j] = ∂y_j(t_i)/∂p_k`.
622    pub sensitivity: Vec<S>,
623    /// Number of state variables `N`.
624    pub n_states: usize,
625    /// Number of parameters `N_s`.
626    pub n_params: usize,
627    /// Solver statistics from the underlying integration.
628    pub stats: SolverStats,
629    /// Was the integration successful?
630    pub success: bool,
631    /// Error description if the integration failed.
632    pub message: String,
633}
634
635impl<S: Scalar> SensitivityResult<S> {
636    /// Number of output time points.
637    pub fn len(&self) -> usize {
638        self.t.len()
639    }
640
641    /// `true` if there are no output time points.
642    pub fn is_empty(&self) -> bool {
643        self.t.is_empty()
644    }
645
646    /// State vector at output index `i` (length `N`).
647    pub fn y_at(&self, i: usize) -> &[S] {
648        let n = self.n_states;
649        let off = i * n;
650        &self.y[off..off + n]
651    }
652
653    /// Full sensitivity block at output index `i`, column-major
654    /// (length `N · N_s`). `sensitivity_at(i)[k*N + j] = ∂y_j(t_i)/∂p_k`.
655    pub fn sensitivity_at(&self, i: usize) -> &[S] {
656        let block = self.n_states * self.n_params;
657        let off = i * block;
658        &self.sensitivity[off..off + block]
659    }
660
661    /// Contiguous slice `S_{:,k}(t_i) = ∂y/∂p_k` at output index `i`,
662    /// length `N`. Free slice thanks to the column-major flattening.
663    pub fn sensitivity_for_param(&self, i: usize, k: usize) -> &[S] {
664        let n = self.n_states;
665        let block = n * self.n_params;
666        let off = i * block + k * n;
667        &self.sensitivity[off..off + n]
668    }
669
670    /// Single component `∂y_state(t_i)/∂p_param`.
671    pub fn dyi_dpj(&self, i: usize, state: usize, param: usize) -> S {
672        let n = self.n_states;
673        let block = n * self.n_params;
674        self.sensitivity[i * block + param * n + state]
675    }
676
677    /// State at the final output time. Returns an empty slice if the result
678    /// has no time points.
679    pub fn final_state(&self) -> &[S] {
680        if self.is_empty() {
681            &[]
682        } else {
683            self.y_at(self.len() - 1)
684        }
685    }
686
687    /// Sensitivity block at the final output time, column-major (length
688    /// `N · N_s`). Empty slice when the result has no time points.
689    pub fn final_sensitivity(&self) -> &[S] {
690        if self.is_empty() {
691            &[]
692        } else {
693            self.sensitivity_at(self.len() - 1)
694        }
695    }
696
697    /// Normalised (logarithmic) sensitivity at output index `i`:
698    /// `(p_k / y_j) · ∂y_j/∂p_k`, column-major in the same shape as
699    /// [`Self::sensitivity_at`]. Components where `|y_j|` falls below
700    /// `1e-15 · max(1, |y|_∞)` are reported as zero to avoid blow-up.
701    pub fn normalized_sensitivity_at(&self, i: usize, p_nominal: &[S]) -> Vec<S> {
702        assert_eq!(
703            p_nominal.len(),
704            self.n_params,
705            "p_nominal length {} does not match n_params {}",
706            p_nominal.len(),
707            self.n_params,
708        );
709        let n = self.n_states;
710        let np = self.n_params;
711        let y = self.y_at(i);
712        let s = self.sensitivity_at(i);
713
714        let mut y_max = S::ZERO;
715        for &v in y {
716            let av = v.abs();
717            if av > y_max {
718                y_max = av;
719            }
720        }
721        let zero_threshold = S::from_f64(1e-15) * (S::ONE + y_max);
722
723        let mut out = vec![S::ZERO; n * np];
724        for k in 0..np {
725            for j in 0..n {
726                let yj = y[j];
727                if yj.abs() <= zero_threshold {
728                    out[k * n + j] = S::ZERO;
729                } else {
730                    out[k * n + j] = (p_nominal[k] / yj) * s[k * n + j];
731                }
732            }
733        }
734        out
735    }
736}
737
738/// Closure-shaped adapter for [`solve_forward_sensitivity_with`].
739///
740/// Wraps a closure of shape `Fn(t, y, p, dydt)` and a fixed parameter vector
741/// into a [`ParametricOdeSystem`] using FD-default Jacobians. Public so that
742/// callers writing a one-shot harness can construct it explicitly when they
743/// need to share it across multiple solver invocations.
744pub struct ClosureSystem<S: Scalar, F> {
745    rhs: F,
746    params: Vec<S>,
747    n_states: usize,
748}
749
750impl<S: Scalar, F> ClosureSystem<S, F>
751where
752    F: Fn(S, &[S], &[S], &mut [S]),
753{
754    /// Build a closure-backed [`ParametricOdeSystem`].
755    pub fn new(rhs: F, params: Vec<S>, n_states: usize) -> Self {
756        Self {
757            rhs,
758            params,
759            n_states,
760        }
761    }
762}
763
764impl<S: Scalar, F> ParametricOdeSystem<S> for ClosureSystem<S, F>
765where
766    F: Fn(S, &[S], &[S], &mut [S]),
767{
768    fn n_states(&self) -> usize {
769        self.n_states
770    }
771    fn n_params(&self) -> usize {
772        self.params.len()
773    }
774    fn params(&self) -> &[S] {
775        &self.params
776    }
777    fn rhs_with_params(&self, t: S, y: &[S], p: &[S], dydt: &mut [S]) {
778        (self.rhs)(t, y, p, dydt)
779    }
780}
781
782/// Compute the forward sensitivity matrix `S(t) = ∂y(t) / ∂p` of an ODE
783/// solution using any [`Solver`].
784///
785/// Builds the augmented state `z = [y; vec(S)]` (column-major sensitivity
786/// flattening; see [module-level docs](self)), drives `Sol` over the
787/// augmented system, and splits the trajectory back into `y` and
788/// sensitivity blocks at every output time the solver produced.
789///
790/// # Errors
791///
792/// Returns whatever error `Sol::solve` raises. If the underlying integration
793/// returns `success = false` (e.g. step-size collapse or max-steps reached),
794/// the returned [`SensitivityResult`] propagates that with empty trajectory
795/// vectors and the solver's diagnostic message.
796///
797/// # Example
798///
799/// ```
800/// use numra_ode::sensitivity::{solve_forward_sensitivity, ParametricOdeSystem};
801/// use numra_ode::{DoPri5, SolverOptions};
802///
803/// // dy/dt = -k * y, y(0) = 1, k = 0.5.
804/// // Analytical: y(t) = exp(-k t), ∂y/∂k = -t exp(-k t).
805/// struct Decay { k: f64 }
806/// impl ParametricOdeSystem<f64> for Decay {
807///     fn n_states(&self) -> usize { 1 }
808///     fn n_params(&self) -> usize { 1 }
809///     fn params(&self) -> &[f64] { std::slice::from_ref(&self.k) }
810///     fn rhs_with_params(&self, _t: f64, y: &[f64], p: &[f64], dy: &mut [f64]) {
811///         dy[0] = -p[0] * y[0];
812///     }
813///     fn jacobian_y(&self, _t: f64, _y: &[f64], jy: &mut [f64]) { jy[0] = -self.k; }
814///     fn jacobian_p(&self, _t: f64, y: &[f64], jp: &mut [f64]) { jp[0] = -y[0]; }
815///     fn has_analytical_jacobian_y(&self) -> bool { true }
816///     fn has_analytical_jacobian_p(&self) -> bool { true }
817/// }
818///
819/// let r = solve_forward_sensitivity::<DoPri5, _, _>(
820///     &Decay { k: 0.5 },
821///     0.0, 2.0,
822///     &[1.0],
823///     &SolverOptions::default().rtol(1e-8).atol(1e-10),
824/// ).unwrap();
825///
826/// let last = r.len() - 1;
827/// let dy_dk = r.dyi_dpj(last, 0, 0);
828/// let analytical = -r.t[last] * (-0.5 * r.t[last]).exp();
829/// assert!((dy_dk - analytical).abs() < 1e-5);
830/// ```
831pub fn solve_forward_sensitivity<Sol, S, Sys>(
832    system: &Sys,
833    t0: S,
834    tf: S,
835    y0: &[S],
836    options: &SolverOptions<S>,
837) -> Result<SensitivityResult<S>, SolverError>
838where
839    Sol: Solver<S>,
840    S: Scalar,
841    Sys: ParametricOdeSystem<S>,
842{
843    let n = system.n_states();
844    let np = system.n_params();
845    assert_eq!(
846        y0.len(),
847        n,
848        "solve_forward_sensitivity: y0.len() = {} but n_states = {}",
849        y0.len(),
850        n,
851    );
852    assert_eq!(
853        system.params().len(),
854        np,
855        "solve_forward_sensitivity: params().len() = {} but n_params = {}",
856        system.params().len(),
857        np,
858    );
859
860    let aug = AugmentedSystem::new(system);
861    let z0 = aug.initial_augmented(y0);
862    let aug_dim = aug.augmented_dim();
863
864    let aug_result = Sol::solve(&aug, t0, tf, &z0, options)?;
865
866    if !aug_result.success {
867        return Ok(SensitivityResult {
868            t: Vec::new(),
869            y: Vec::new(),
870            sensitivity: Vec::new(),
871            n_states: n,
872            n_params: np,
873            stats: aug_result.stats,
874            success: false,
875            message: aug_result.message,
876        });
877    }
878
879    let n_times = aug_result.len();
880    let mut t = Vec::with_capacity(n_times);
881    let mut y = Vec::with_capacity(n_times * n);
882    let mut sensitivity = Vec::with_capacity(n_times * n * np);
883
884    for i in 0..n_times {
885        t.push(aug_result.t[i]);
886        let block_start = i * aug_dim;
887        let state_block = &aug_result.y[block_start..block_start + n];
888        let sens_block = &aug_result.y[block_start + n..block_start + aug_dim];
889        y.extend_from_slice(state_block);
890        sensitivity.extend_from_slice(sens_block);
891    }
892
893    Ok(SensitivityResult {
894        t,
895        y,
896        sensitivity,
897        n_states: n,
898        n_params: np,
899        stats: aug_result.stats,
900        success: true,
901        message: String::new(),
902    })
903}
904
905/// Closure-shaped convenience wrapper around [`solve_forward_sensitivity`].
906///
907/// Wraps `rhs(t, y, p, dydt)` plus a parameter vector into an internal
908/// [`ClosureSystem`] (FD-default Jacobians) and forwards. Suitable for
909/// one-shot analyses, tests, and REPL-style scripts. For production usage
910/// — particularly stiff problems where analytical Jacobians materially
911/// change runtime — implement [`ParametricOdeSystem`] directly and call
912/// [`solve_forward_sensitivity`].
913///
914/// # Example
915///
916/// ```
917/// use numra_ode::{DoPri5, SolverOptions};
918/// use numra_ode::sensitivity::solve_forward_sensitivity_with;
919///
920/// // dy/dt = -k * y, y(0) = 1, k = 0.5.
921/// let r = solve_forward_sensitivity_with::<DoPri5, f64, _>(
922///     |_t, y, p, dy| { dy[0] = -p[0] * y[0]; },
923///     &[1.0],
924///     &[0.5],
925///     0.0, 2.0,
926///     &SolverOptions::default().rtol(1e-8).atol(1e-10),
927/// ).unwrap();
928///
929/// let last = r.len() - 1;
930/// let dy_dk = r.dyi_dpj(last, 0, 0);
931/// let analytical: f64 = -r.t[last] * (-0.5 * r.t[last]).exp();
932/// assert!((dy_dk - analytical).abs() < 1e-4);
933/// ```
934pub fn solve_forward_sensitivity_with<Sol, S, F>(
935    rhs: F,
936    y0: &[S],
937    params: &[S],
938    t0: S,
939    tf: S,
940    options: &SolverOptions<S>,
941) -> Result<SensitivityResult<S>, SolverError>
942where
943    Sol: Solver<S>,
944    S: Scalar,
945    F: Fn(S, &[S], &[S], &mut [S]),
946{
947    let system = ClosureSystem {
948        rhs,
949        params: params.to_vec(),
950        n_states: y0.len(),
951    };
952    solve_forward_sensitivity::<Sol, S, _>(&system, t0, tf, y0, options)
953}
954
955// ============================================================================
956// Initial-condition sensitivity (state-transition matrix)
957//
958// Specialises the variational machinery above to the IC case: integrates
959// `Ṡ = J_y · S` with `S(t₀) = I_N`, producing `Φ(t) = ∂y(t)/∂y₀` —
960// the state-transition matrix of the linearised flow. Reuses
961// `AugmentedSystem` and `solve_forward_sensitivity` unchanged; no fork.
962//
963// The user-facing API takes an `OdeSystem<S>` (or RHS closure) and returns
964// a `StateTransitionResult<S>` whose accessors speak Φ(t)-vocabulary only.
965// Internally a private `IcAsParametric` wrapper reinterprets the plain
966// system as a `ParametricOdeSystem` with `n_params := n_states`,
967// `J_p ≡ 0`, and `S(t₀) = I`. The required dummy `params()` slice is the
968// only seam, encapsulated behind the wrapper and pinned by the test
969// `ic_dummy_params_are_unread` below.
970//
971// Invariant **IC-NO-PARAM-READ.** `IcAsParametric::rhs_with_params(t,y,p,dy)`
972// never reads `p`. The wrapped `OdeSystem::rhs(t,y,dy)` has no `p` argument
973// and cannot. Therefore `∂f/∂p ≡ 0` is mathematically exact (not "small in
974// some norm"), and `IcAsParametric::jacobian_p` writing zeros with
975// `has_analytical_jacobian_p() = true` is a correct analytical declaration
976// — `AugmentedSystem`'s debug consistency check at `sensitivity.rs:373` is
977// truthfully suppressed by this flag, not silenced.
978// ============================================================================
979
980/// Private wrapper: makes an `OdeSystem<S>` look like a `ParametricOdeSystem<S>`
981/// whose "parameters" are seeded so the forward-sensitivity flow computes
982/// `Φ(t) = ∂y(t)/∂y₀` instead of `∂y(t)/∂p`.
983struct IcAsParametric<'a, S: Scalar, Sys: OdeSystem<S> + ?Sized> {
984    inner: &'a Sys,
985    /// Dummy parameter slice of length `n_states`. Length-only requirement
986    /// from `ParametricOdeSystem` (asserted at solve entry, line 852–858).
987    /// Provably unread by `rhs_with_params`; pinned by
988    /// `ic_dummy_params_are_unread`.
989    dummy_p: Vec<S>,
990}
991
992impl<'a, S: Scalar, Sys: OdeSystem<S> + ?Sized> ParametricOdeSystem<S>
993    for IcAsParametric<'a, S, Sys>
994{
995    fn n_states(&self) -> usize {
996        self.inner.dim()
997    }
998    fn n_params(&self) -> usize {
999        self.inner.dim()
1000    }
1001    fn params(&self) -> &[S] {
1002        &self.dummy_p
1003    }
1004
1005    fn rhs_with_params(&self, t: S, y: &[S], _p: &[S], dy: &mut [S]) {
1006        // IC-NO-PARAM-READ: `_p` is provably unread; `inner.rhs` has no
1007        // `p` argument and cannot consume it.
1008        self.inner.rhs(t, y, dy);
1009    }
1010
1011    fn jacobian_y(&self, t: S, y: &[S], jy: &mut [S]) {
1012        // Forwards to the inner system's Jacobian — analytical override if
1013        // the user supplied one (e.g. `AutodiffJacobianSystem` from the
1014        // `autodiff` feature), or `OdeSystem`'s FD default otherwise.
1015        self.inner.jacobian(t, y, jy);
1016    }
1017
1018    fn jacobian_p(&self, _t: S, _y: &[S], jp: &mut [S]) {
1019        // J_p ≡ 0 analytically (IC-NO-PARAM-READ ⇒ ∂f/∂p ≡ 0 exactly).
1020        for slot in jp.iter_mut() {
1021            *slot = S::ZERO;
1022        }
1023    }
1024
1025    fn initial_sensitivity(&self, _y0: &[S], s0: &mut [S]) {
1026        // Column-major identity: s0[k*N + i] = δ_{ik}.
1027        let n = self.inner.dim();
1028        for slot in s0.iter_mut() {
1029            *slot = S::ZERO;
1030        }
1031        for k in 0..n {
1032            s0[k * n + k] = S::ONE;
1033        }
1034    }
1035
1036    fn has_analytical_jacobian_y(&self) -> bool {
1037        // Claim analytical so AugmentedSystem honours the forwarded
1038        // `inner.jacobian` (which may itself be analytical or FD-default).
1039        // The debug consistency check is then irrelevant for this flag.
1040        true
1041    }
1042
1043    fn has_analytical_jacobian_p(&self) -> bool {
1044        // Truthful: J_p ≡ 0 is analytically exact per IC-NO-PARAM-READ.
1045        true
1046    }
1047}
1048
1049/// Result of an initial-condition sensitivity solve.
1050///
1051/// Carries the state trajectory `y(t)` and the state-transition matrix
1052/// `Φ(t) = ∂y(t) / ∂y₀ ∈ ℝ^{N×N}` of the linearised flow, evaluated at every
1053/// output time the underlying solver produced.
1054///
1055/// # Φ(t) layout
1056///
1057/// Column-major within each per-time block: `phi(i)[k*N + j] = Φ(t_i)_{j,k}
1058/// = ∂y_j(t_i) / ∂y_{0,k}`. The column-major flattening makes the j-th
1059/// column (the trajectory's response at time `t_i` to a unit perturbation
1060/// of `y_{0,k}`) a free contiguous slice — see [`Self::phi_column`].
1061///
1062/// # Vocabulary
1063///
1064/// All accessors speak state-transition-matrix vocabulary
1065/// (`Φ`, `∂y_i/∂y_{0,j}`, monodromy). The type holds the underlying
1066/// [`SensitivityResult`] in a private field; it does **not** `Deref` to it
1067/// and does **not** re-export the parameter-shaped accessors.
1068#[derive(Clone, Debug)]
1069pub struct StateTransitionResult<S: Scalar> {
1070    inner: SensitivityResult<S>,
1071}
1072
1073impl<S: Scalar> StateTransitionResult<S> {
1074    /// Output time points (length `n_times`). `t()[i]` is the i-th output
1075    /// time; `phi(i)` is `Φ(t()[i])`.
1076    pub fn t(&self) -> &[S] {
1077        &self.inner.t
1078    }
1079
1080    /// Number of output time points.
1081    pub fn len(&self) -> usize {
1082        self.inner.len()
1083    }
1084
1085    /// `true` iff the result has no output time points.
1086    pub fn is_empty(&self) -> bool {
1087        self.inner.is_empty()
1088    }
1089
1090    /// Underlying state dimension `N`.
1091    pub fn dim(&self) -> usize {
1092        self.inner.n_states
1093    }
1094
1095    /// State `y(t_i)` at output index `i` (length `N`).
1096    pub fn y(&self, i: usize) -> &[S] {
1097        self.inner.y_at(i)
1098    }
1099
1100    /// State at the final output time, length `N`. Empty slice when the
1101    /// result has no time points.
1102    pub fn final_y(&self) -> &[S] {
1103        self.inner.final_state()
1104    }
1105
1106    /// Full state-transition matrix `Φ(t_i) ∈ ℝ^{N×N}` at output index `i`,
1107    /// column-major. `phi(i)[k*N + j] = Φ(t_i)_{j,k} = ∂y_j(t_i)/∂y_{0,k}`.
1108    pub fn phi(&self, i: usize) -> &[S] {
1109        self.inner.sensitivity_at(i)
1110    }
1111
1112    /// Single entry `Φ(t_i)_{row, col} = ∂y_row(t_i) / ∂y_{0, col}`.
1113    pub fn phi_ij(&self, i: usize, row: usize, col: usize) -> S {
1114        self.inner.dyi_dpj(i, row, col)
1115    }
1116
1117    /// Contiguous slice `Φ(t_i)_{:, col}` of length `N` — the trajectory's
1118    /// response at `t_i` to a unit perturbation of `y_{0, col}`. Free slice
1119    /// thanks to the column-major flattening.
1120    pub fn phi_column(&self, i: usize, col: usize) -> &[S] {
1121        self.inner.sensitivity_for_param(i, col)
1122    }
1123
1124    /// `Φ(t_f)`, the state-transition matrix at the final output time.
1125    /// When `(t_f − t_0)` equals one period of a periodic orbit this is
1126    /// the *monodromy matrix*, whose eigenvalues are the orbit's
1127    /// characteristic (Floquet) multipliers. The type does not enforce
1128    /// periodicity; it only names the natural use. Empty slice when the
1129    /// result has no time points.
1130    pub fn final_phi(&self) -> &[S] {
1131        self.inner.final_sensitivity()
1132    }
1133
1134    /// `true` iff the underlying integration succeeded.
1135    pub fn success(&self) -> bool {
1136        self.inner.success
1137    }
1138
1139    /// Solver diagnostic message (empty on success).
1140    pub fn message(&self) -> &str {
1141        &self.inner.message
1142    }
1143
1144    /// Solver statistics from the underlying integration.
1145    pub fn stats(&self) -> &SolverStats {
1146        &self.inner.stats
1147    }
1148}
1149
1150/// Compute the state-transition matrix `Φ(t) = ∂y(t) / ∂y₀` of the
1151/// linearised flow of `dy/dt = f(t, y)`, using any [`Solver`].
1152///
1153/// Integrates the variational equations `Ṡ = J_y · S` with seed
1154/// `S(t₀) = I_N` over the same [`AugmentedSystem`] used for parameter
1155/// sensitivity, then extracts the state-transition trajectory.
1156///
1157/// The user supplies only the ODE system; the IC seeding (identity initial
1158/// sensitivity, zero parameter forcing) is performed internally. `J_y` is
1159/// obtained from [`OdeSystem::jacobian`] — the user's analytical override
1160/// if any, the trait's forward-FD default otherwise. For an
1161/// exact-to-round-off `J_y` from autodiff, wrap the system in
1162/// [`crate::AutodiffJacobianSystem`] (behind the `autodiff` feature).
1163///
1164/// # Example: scalar exponential decay
1165///
1166/// `dy/dt = -k y` has closed-form `Φ(t) = exp(-k t)`.
1167///
1168/// ```
1169/// use numra_ode::sensitivity::solve_initial_condition_sensitivity_with;
1170/// use numra_ode::{DoPri5, SolverOptions};
1171///
1172/// let k = 0.5_f64;
1173/// let result = solve_initial_condition_sensitivity_with::<DoPri5, f64, _>(
1174///     move |_t, y, dy| { dy[0] = -k * y[0]; },
1175///     &[1.0],
1176///     0.0, 2.0,
1177///     &SolverOptions::default().rtol(1e-9).atol(1e-12),
1178/// ).unwrap();
1179///
1180/// let last = result.len() - 1;
1181/// let phi = result.phi_ij(last, 0, 0);
1182/// let analytical = (-k * result.t()[last]).exp();
1183/// assert!((phi - analytical).abs() < 1e-7);
1184/// ```
1185pub fn solve_initial_condition_sensitivity<Sol, S, Sys>(
1186    system: &Sys,
1187    t0: S,
1188    tf: S,
1189    y0: &[S],
1190    options: &SolverOptions<S>,
1191) -> Result<StateTransitionResult<S>, SolverError>
1192where
1193    Sol: Solver<S>,
1194    S: Scalar,
1195    Sys: OdeSystem<S> + ?Sized,
1196{
1197    let n = system.dim();
1198    assert_eq!(
1199        y0.len(),
1200        n,
1201        "solve_initial_condition_sensitivity: y0.len() = {} but dim() = {}",
1202        y0.len(),
1203        n,
1204    );
1205
1206    let wrapper = IcAsParametric {
1207        inner: system,
1208        dummy_p: vec![S::ZERO; n],
1209    };
1210    let inner = solve_forward_sensitivity::<Sol, S, _>(&wrapper, t0, tf, y0, options)?;
1211    Ok(StateTransitionResult { inner })
1212}
1213
1214/// Closure-shaped convenience wrapper around
1215/// [`solve_initial_condition_sensitivity`].
1216///
1217/// Wraps `rhs(t, y, dydt)` into an internal [`OdeProblem`] (forward-FD
1218/// Jacobian via the [`OdeSystem`] default) and forwards. Suitable for
1219/// one-shot analyses and REPL-style scripts. For an analytical `J_y` or an
1220/// autodiff-derived `J_y`, implement [`OdeSystem`] directly (with a
1221/// `jacobian` override or by wrapping in [`crate::AutodiffJacobianSystem`])
1222/// and call [`solve_initial_condition_sensitivity`].
1223pub fn solve_initial_condition_sensitivity_with<Sol, S, F>(
1224    rhs: F,
1225    y0: &[S],
1226    t0: S,
1227    tf: S,
1228    options: &SolverOptions<S>,
1229) -> Result<StateTransitionResult<S>, SolverError>
1230where
1231    Sol: Solver<S>,
1232    S: Scalar,
1233    F: Fn(S, &[S], &mut [S]),
1234{
1235    let problem = OdeProblem::new(rhs, t0, tf, y0.to_vec());
1236    solve_initial_condition_sensitivity::<Sol, S, _>(&problem, t0, tf, y0, options)
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    /// Minimal scalar decay system used to drive the trait through
1244    /// `OdeSystem::rhs` and `OdeSystem::jacobian` without involving a solver.
1245    struct ExpDecay {
1246        k: f64,
1247    }
1248
1249    impl ParametricOdeSystem<f64> for ExpDecay {
1250        fn n_states(&self) -> usize {
1251            1
1252        }
1253        fn n_params(&self) -> usize {
1254            1
1255        }
1256        fn params(&self) -> &[f64] {
1257            std::slice::from_ref(&self.k)
1258        }
1259        fn rhs_with_params(&self, _t: f64, y: &[f64], p: &[f64], dy: &mut [f64]) {
1260            dy[0] = -p[0] * y[0];
1261        }
1262        fn jacobian_y(&self, _t: f64, _y: &[f64], jy: &mut [f64]) {
1263            jy[0] = -self.k;
1264        }
1265        fn jacobian_p(&self, _t: f64, y: &[f64], jp: &mut [f64]) {
1266            jp[0] = -y[0];
1267        }
1268        fn has_analytical_jacobian_y(&self) -> bool {
1269            true
1270        }
1271        fn has_analytical_jacobian_p(&self) -> bool {
1272            true
1273        }
1274    }
1275
1276    /// 2-state, 2-parameter linear system (mirrors the module-doc example).
1277    /// Lets us exercise the asymmetric J_y / J_p layouts in code.
1278    struct Lin2 {
1279        p: [f64; 2],
1280    }
1281
1282    impl ParametricOdeSystem<f64> for Lin2 {
1283        fn n_states(&self) -> usize {
1284            2
1285        }
1286        fn n_params(&self) -> usize {
1287            2
1288        }
1289        fn params(&self) -> &[f64] {
1290            &self.p
1291        }
1292        fn rhs_with_params(&self, _t: f64, y: &[f64], p: &[f64], dy: &mut [f64]) {
1293            dy[0] = -p[0] * y[0] + p[1] * y[1];
1294            dy[1] = -p[1] * y[1];
1295        }
1296        fn jacobian_y(&self, _t: f64, _y: &[f64], jy: &mut [f64]) {
1297            jy[0 * 2 + 0] = -self.p[0];
1298            jy[0 * 2 + 1] = self.p[1];
1299            jy[1 * 2 + 0] = 0.0;
1300            jy[1 * 2 + 1] = -self.p[1];
1301        }
1302        fn jacobian_p(&self, _t: f64, y: &[f64], jp: &mut [f64]) {
1303            jp[0 * 2 + 0] = -y[0];
1304            jp[0 * 2 + 1] = 0.0;
1305            jp[1 * 2 + 0] = y[1];
1306            jp[1 * 2 + 1] = -y[1];
1307        }
1308        fn has_analytical_jacobian_y(&self) -> bool {
1309            true
1310        }
1311        fn has_analytical_jacobian_p(&self) -> bool {
1312            true
1313        }
1314    }
1315
1316    #[test]
1317    fn augmented_dim_and_initial_state() {
1318        let aug = AugmentedSystem::new(Lin2 { p: [1.0, 0.5] });
1319        assert_eq!(aug.augmented_dim(), 2 * (1 + 2));
1320        let z0 = aug.initial_augmented(&[1.0, 2.0]);
1321        assert_eq!(z0.len(), 6);
1322        assert_eq!(&z0[..2], &[1.0, 2.0]);
1323        for s in &z0[2..] {
1324            assert_eq!(*s, 0.0);
1325        }
1326    }
1327
1328    #[test]
1329    fn augmented_rhs_matches_hand_derivation() {
1330        // System: dy/dt = -k y, S_{0,0} = ∂y/∂k.
1331        // dS/dt = J_y * S + J_p = -k * S - y.
1332        let aug = AugmentedSystem::new(ExpDecay { k: 0.5 });
1333        let z = vec![2.0, 1.0]; // y = 2.0, S = 1.0
1334        let mut dz = vec![0.0; 2];
1335        aug.rhs(0.0, &z, &mut dz);
1336
1337        // dy/dt = -0.5 * 2.0 = -1.0
1338        assert!((dz[0] + 1.0).abs() < 1e-12);
1339        // dS/dt = -0.5 * 1.0 + (-2.0) = -2.5
1340        assert!((dz[1] + 2.5).abs() < 1e-12);
1341    }
1342
1343    #[test]
1344    fn augmented_rhs_matches_hand_derivation_two_param() {
1345        // Lin2 system at y = (1, 2), p = (1, 0.5), S = identity (initial sensitivity ID).
1346        let aug = AugmentedSystem::new(Lin2 { p: [1.0, 0.5] });
1347
1348        // z = [y_0, y_1, S_{0,0}, S_{1,0}, S_{0,1}, S_{1,1}]
1349        //     [ 1,    2,    1,       0,       0,       1   ]
1350        let z = vec![1.0, 2.0, 1.0, 0.0, 0.0, 1.0];
1351        let mut dz = vec![0.0; 6];
1352        aug.rhs(0.0, &z, &mut dz);
1353
1354        // dy/dt
1355        // dy_0/dt = -1*1 + 0.5*2 = 0.0
1356        // dy_1/dt =        -0.5*2 = -1.0
1357        assert!((dz[0] - 0.0).abs() < 1e-12);
1358        assert!((dz[1] + 1.0).abs() < 1e-12);
1359
1360        // dS_{:,0}/dt = J_y · [1; 0] + J_p_{:,0}
1361        // J_y · [1; 0] = [-1; 0];   J_p_{:,0} = [-1; 0]; total = [-2; 0]
1362        assert!((dz[2] + 2.0).abs() < 1e-12);
1363        assert!((dz[3] - 0.0).abs() < 1e-12);
1364
1365        // dS_{:,1}/dt = J_y · [0; 1] + J_p_{:,1}
1366        // J_y · [0; 1] = [0.5; -0.5]; J_p_{:,1} = [2; -2]; total = [2.5; -2.5]
1367        assert!((dz[4] - 2.5).abs() < 1e-12);
1368        assert!((dz[5] + 2.5).abs() < 1e-12);
1369    }
1370
1371    #[test]
1372    fn augmented_jacobian_is_block_diagonal_with_jy() {
1373        let aug = AugmentedSystem::new(Lin2 { p: [1.0, 0.5] });
1374        let z = vec![1.0, 2.0, 1.0, 0.0, 0.0, 1.0];
1375        let dim = aug.augmented_dim();
1376        let mut jac = vec![0.0; dim * dim];
1377        aug.jacobian(0.0, &z, &mut jac);
1378
1379        // Expected J_y at p=(1, 0.5):
1380        //   [-1.0  0.5]
1381        //   [ 0.0 -0.5]
1382        let jy = [-1.0_f64, 0.5, 0.0, -0.5];
1383
1384        for block in 0..3 {
1385            let row0 = block * 2;
1386            let col0 = block * 2;
1387            for i in 0..2 {
1388                for j in 0..2 {
1389                    let got = jac[(row0 + i) * dim + (col0 + j)];
1390                    let want = jy[i * 2 + j];
1391                    assert!(
1392                        (got - want).abs() < 1e-12,
1393                        "diag block {block}, ({i},{j}): got {got}, want {want}"
1394                    );
1395                }
1396            }
1397        }
1398
1399        // Off-diagonal blocks must be zero.
1400        for br in 0..3 {
1401            for bc in 0..3 {
1402                if br == bc {
1403                    continue;
1404                }
1405                for i in 0..2 {
1406                    for j in 0..2 {
1407                        let r = br * 2 + i;
1408                        let c = bc * 2 + j;
1409                        assert_eq!(jac[r * dim + c], 0.0, "off-diag ({br},{bc}) ({i},{j})");
1410                    }
1411                }
1412            }
1413        }
1414    }
1415
1416    #[test]
1417    fn fd_default_jacobian_y_matches_analytical() {
1418        // Re-implement Lin2 without analytical J_y so the FD default is exercised.
1419        struct Lin2Fd {
1420            p: [f64; 2],
1421        }
1422        impl ParametricOdeSystem<f64> for Lin2Fd {
1423            fn n_states(&self) -> usize {
1424                2
1425            }
1426            fn n_params(&self) -> usize {
1427                2
1428            }
1429            fn params(&self) -> &[f64] {
1430                &self.p
1431            }
1432            fn rhs_with_params(&self, _t: f64, y: &[f64], p: &[f64], dy: &mut [f64]) {
1433                dy[0] = -p[0] * y[0] + p[1] * y[1];
1434                dy[1] = -p[1] * y[1];
1435            }
1436            // No jacobian_y / jacobian_p override — FD defaults are used.
1437        }
1438
1439        let analytical = Lin2 { p: [1.0, 0.5] };
1440        let fd = Lin2Fd { p: [1.0, 0.5] };
1441
1442        let y = [1.0, 2.0];
1443        let mut a_jy = [0.0; 4];
1444        let mut f_jy = [0.0; 4];
1445        analytical.jacobian_y(0.0, &y, &mut a_jy);
1446        fd.jacobian_y(0.0, &y, &mut f_jy);
1447        for k in 0..4 {
1448            assert!(
1449                (a_jy[k] - f_jy[k]).abs() < 1e-7,
1450                "j_y[{k}] analytical={} fd={}",
1451                a_jy[k],
1452                f_jy[k]
1453            );
1454        }
1455
1456        let mut a_jp = [0.0; 4];
1457        let mut f_jp = [0.0; 4];
1458        analytical.jacobian_p(0.0, &y, &mut a_jp);
1459        fd.jacobian_p(0.0, &y, &mut f_jp);
1460        for k in 0..4 {
1461            assert!(
1462                (a_jp[k] - f_jp[k]).abs() < 1e-7,
1463                "j_p[{k}] analytical={} fd={}",
1464                a_jp[k],
1465                f_jp[k]
1466            );
1467        }
1468    }
1469
1470    #[test]
1471    fn sensitivity_result_accessors() {
1472        // Hand-construct a result for a 2-state, 3-param trajectory at 2 time points.
1473        // sensitivity layout per time block (column-major, length N*N_s = 6):
1474        //   [ S_{0,0}  S_{1,0}  | S_{0,1}  S_{1,1}  | S_{0,2}  S_{1,2} ]
1475        let res = SensitivityResult {
1476            t: vec![0.0, 1.0],
1477            y: vec![1.0, 2.0, 0.5, 1.5],
1478            sensitivity: vec![
1479                // t = 0
1480                0.1, 0.2, 0.3, 0.4, 0.5, 0.6, // t = 1
1481                1.1, 1.2, 1.3, 1.4, 1.5, 1.6,
1482            ],
1483            n_states: 2,
1484            n_params: 3,
1485            stats: SolverStats::new(),
1486            success: true,
1487            message: String::new(),
1488        };
1489
1490        assert_eq!(res.len(), 2);
1491        assert_eq!(res.y_at(1), &[0.5, 1.5]);
1492        assert_eq!(res.sensitivity_at(0).len(), 6);
1493        assert_eq!(res.sensitivity_for_param(1, 2), &[1.5, 1.6]);
1494        assert!((res.dyi_dpj(0, 1, 0) - 0.2).abs() < 1e-12);
1495        assert!((res.dyi_dpj(1, 0, 2) - 1.5).abs() < 1e-12);
1496        assert_eq!(res.final_state(), &[0.5, 1.5]);
1497        assert_eq!(res.final_sensitivity().len(), 6);
1498    }
1499
1500    #[test]
1501    fn normalized_sensitivity_handles_zero_state_safely() {
1502        let res = SensitivityResult {
1503            t: vec![0.0],
1504            y: vec![1.0, 0.0],
1505            sensitivity: vec![0.5, 0.7],
1506            n_states: 2,
1507            n_params: 1,
1508            stats: SolverStats::new(),
1509            success: true,
1510            message: String::new(),
1511        };
1512        let norm = res.normalized_sensitivity_at(0, &[2.0]);
1513        // (p / y_0) · S_{0,0} = (2 / 1) · 0.5 = 1.0
1514        assert!((norm[0] - 1.0).abs() < 1e-12);
1515        // y_1 = 0 → reported as zero rather than ∞.
1516        assert_eq!(norm[1], 0.0);
1517    }
1518
1519    // ---------------------------------------------------------------
1520    // Solve entry-point sanity tests.
1521    //
1522    // The full solver-coverage matrix lives in
1523    // numra-ode/tests/sensitivity_regression.rs (commit 3).
1524    // ---------------------------------------------------------------
1525
1526    use crate::DoPri5;
1527    use crate::SolverOptions;
1528
1529    #[test]
1530    fn solve_forward_sensitivity_with_dopri5_matches_analytical_decay() {
1531        // dy/dt = -k y, y(0) = 1, k = 0.5.
1532        // Analytical: ∂y/∂k(t) = -t · exp(-k t).
1533        let result = solve_forward_sensitivity_with::<DoPri5, f64, _>(
1534            |_t, y, p, dy| {
1535                dy[0] = -p[0] * y[0];
1536            },
1537            &[1.0],
1538            &[0.5],
1539            0.0,
1540            2.0,
1541            &SolverOptions::default().rtol(1e-8).atol(1e-10),
1542        )
1543        .expect("solve failed");
1544
1545        assert!(result.success);
1546        assert_eq!(result.n_states, 1);
1547        assert_eq!(result.n_params, 1);
1548        let last = result.len() - 1;
1549        let dy_dk = result.dyi_dpj(last, 0, 0);
1550        let t_last = result.t[last];
1551        let analytical = -t_last * (-0.5 * t_last).exp();
1552        assert!(
1553            (dy_dk - analytical).abs() < 1e-5,
1554            "computed {dy_dk}, analytical {analytical}, |err| = {}",
1555            (dy_dk - analytical).abs()
1556        );
1557    }
1558
1559    #[test]
1560    fn solve_trait_form_matches_closure_form() {
1561        let trait_result = solve_forward_sensitivity::<DoPri5, _, _>(
1562            &ExpDecay { k: 0.5 },
1563            0.0,
1564            2.0,
1565            &[1.0],
1566            &SolverOptions::default().rtol(1e-9).atol(1e-12),
1567        )
1568        .expect("trait solve failed");
1569
1570        let closure_result = solve_forward_sensitivity_with::<DoPri5, f64, _>(
1571            |_t, y, p, dy| {
1572                dy[0] = -p[0] * y[0];
1573            },
1574            &[1.0],
1575            &[0.5],
1576            0.0,
1577            2.0,
1578            &SolverOptions::default().rtol(1e-9).atol(1e-12),
1579        )
1580        .expect("closure solve failed");
1581
1582        // Trait form has analytical Jacobians; closure form falls back to FD.
1583        // Both must converge to the same answer at the final time, modulo FD
1584        // noise.
1585        let n_t = trait_result.len();
1586        let n_c = closure_result.len();
1587        let trait_final = trait_result.dyi_dpj(n_t - 1, 0, 0);
1588        let closure_final = closure_result.dyi_dpj(n_c - 1, 0, 0);
1589        assert!(
1590            (trait_final - closure_final).abs() < 1e-5,
1591            "trait {trait_final} vs closure {closure_final}, |err| = {}",
1592            (trait_final - closure_final).abs()
1593        );
1594    }
1595
1596    #[test]
1597    fn solve_propagates_solver_failure_as_unsuccessful_result() {
1598        // Forcing max_steps = 1 against a multi-step problem makes the
1599        // underlying solver return success = false (or an error). We assert
1600        // that solve_forward_sensitivity surfaces this without panicking.
1601        let opts = SolverOptions::default().rtol(1e-9).atol(1e-12).max_steps(1);
1602
1603        let outcome = solve_forward_sensitivity_with::<DoPri5, f64, _>(
1604            |_t, y, p, dy| {
1605                dy[0] = -p[0] * y[0];
1606            },
1607            &[1.0],
1608            &[0.5],
1609            0.0,
1610            10.0,
1611            &opts,
1612        );
1613
1614        match outcome {
1615            Ok(r) => {
1616                assert!(!r.success, "expected unsuccessful result, got success");
1617                assert!(r.t.is_empty());
1618                assert!(r.y.is_empty());
1619                assert!(r.sensitivity.is_empty());
1620            }
1621            Err(_) => {
1622                // Some solvers return Err directly for max_steps; that's also acceptable.
1623            }
1624        }
1625    }
1626}