outram_foam_basic_lib/math/differentiate.rs
1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
18
19//! **Numerical differentiation of a supplied function** — finite differences,
20//! batched derivatives, and batched Jacobians, dispatched across
21//! [`ComputeBackend`].
22//!
23//! # This is NOT the FV gradient operator
24//!
25//! [`crate::fv_operators`] implements the *spatial* finite-volume `grad`, `div`
26//! and `laplacian` over a mesh: they differentiate a **field** with respect to
27//! **position**, using face fluxes and cell volumes, and they are the right tool
28//! for a PDE discretisation. This module differentiates an arbitrary
29//! **caller-supplied function** with respect to its own arguments, by sampling
30//! it. If you are discretising a transport equation you want `fv_operators`; if
31//! you need `df/dx` of a closure, a property correlation or an ODE right-hand
32//! side, you are in the right place.
33//!
34//! # The problem this exists to solve
35//!
36//! [`crate::ode::OdeSystem::jacobian`] has a default body that is
37//! `unimplemented!()`. Any system that does not hand-code its Jacobian
38//! **panics** the moment [`crate::ode::Rosenbrock23`] — the crate's only stiff
39//! solver — asks for one, and inside
40//! [`crate::ode::parallel::integrate_ensemble`] that panic propagates out
41//! through the `rayon` scope. [`NumericalJacobian`] closes that hole: wrap a
42//! system, and `Rosenbrock23` integrates it with no hand-coded Jacobian at all.
43//! Measured cost of doing so, on Van der Pol (`mu = 5`, `x` in `[0, 10]`,
44//! tolerances `1e-8`) — see "Measured cost against a hand-coded Jacobian" below
45//! — is **1.9x** wall clock for a forward difference and 2.0x for a central
46//! one, for the same answer to all eight printed decimals.
47//!
48//! # Provenance — a generalisation of two settled workspace conventions
49//!
50//! Nothing here is a new algorithm. Both halves of the formulation are taken
51//! from implementations already working in this workspace, and the divergences
52//! are stated rather than left to be discovered.
53//!
54//! **The Jacobian assembly** generalises:
55//!
56//! ```text
57//! crates/outram-park-fork-dwsim-libs/src/columns/linalg.rs:183
58//! pub fn finite_difference_jacobian<F>(f: &mut F, x: &[f64], epsilon: f64)
59//! -> Option<Array2<f64>>
60//! ```
61//!
62//! itself a port of DWSIM's `NewtonRaphson.vb:669-705` (`FunctionGradient`),
63//! used there for the Naphtali-Sandholm column solver's initial Broyden
64//! Jacobian. Kept from it: the **central** stencil, the **relative**
65//! perturbation, and the **failure-is-`Option`** convention — a caller never
66//! receives a matrix it cannot tell apart from a good one. `dwsim-libs` cannot
67//! be depended on from here (this crate has no internal workspace dependencies,
68//! by policy), so this is a reuse of *formulation*, not of code.
69//!
70//! Three deliberate divergences from it, each because the alternative is a known
71//! defect:
72//!
73//! | This module | `finite_difference_jacobian` | Why |
74//! |---|---|---|
75//! | `h = rel * max(\|x\|, min_scale)` | `x*(1±eps)`, or `eps` and `2*eps` when `x == 0` | The `x == 0` branch silently switches to a *one-sided* stencil at a *different* step, so the scheme changes with the data. The `max` floor keeps one scheme everywhere. |
76//! | A failed entry is `NaN` and the status says so | a non-finite entry is written as `0.0` | A zero is a *plausible* Jacobian entry. It cannot be detected downstream, and a Newton or Rosenbrock step built on it returns a wrong answer instead of an error. |
77//! | Divides by the realised step `xp - xm` | divides by the requested `2*eps*x` | `x + h` is not representable, so the requested step is not the step taken. See [`derivative`]. |
78//!
79//! **The step-size rule** is the one already in:
80//!
81//! ```text
82//! crates/outram-park-fork-offbeat/src/rheology/aster/integration.rs:298
83//! pub fn newton_perturbed(...) // h = perturbation * x.abs().max(1.0)
84//! pub fn perturbed_default() -> f64 // f64::EPSILON.cbrt()
85//! ```
86//!
87//! which is upstream Code_Aster's `NEWTON_PERT`. This module adopts both the
88//! `max(|x|, 1)` floor and `eps^(1/3)` for the central scheme verbatim — see
89//! [`CBRT_EPSILON`] and [`DiffSettings::step_for`] — and extends the same
90//! reasoning to the other three orders.
91//!
92//! A third, narrower precedent —
93//! `tampines-steam-tables`' `w_ps_eqm_region4_finite_diff_vol`
94//! (`region_4_vap_liq_equilibrium/speed_of_sound_eqm.rs:83`) — takes
95//! `dv/dp|_s` by central differences with a hard-coded `dp = 1e-4 * p`, clamped
96//! at the minimum table pressure. That is a *relative* step of `1e-4`, sixteen
97//! times coarser than [`CBRT_EPSILON`], which is the right call there because
98//! the IF97 flash it differentiates is far noisier than machine epsilon. It is
99//! recorded here as the standing reminder that **the optimal step assumes the
100//! function is evaluated to rounding accuracy**, and a caller whose function is
101//! noisier should raise [`DiffSettings::relative_step`] accordingly.
102//!
103//! # Achievable accuracy is `sqrt(eps)` to `eps^(4/5)`, NEVER `eps`
104//!
105//! This is the single most common misunderstanding about finite differences, so
106//! it is measured rather than asserted. Truncation error falls as `h^p` while
107//! round-off grows as `eps/h`; their sum is minimised at `h ~ eps^(1/(p+1))`,
108//! where the achievable accuracy is `~ eps^(p/(p+1))`:
109//!
110//! | Scheme | Order `p` | Optimal `h` | Predicted accuracy |
111//! |---|---|---|---|
112//! | [`DiffScheme::Forward`] / [`DiffScheme::Backward`] | 1 | `sqrt(eps) = 1.49e-8` | `sqrt(eps) = 1.49e-8` |
113//! | [`DiffScheme::Central`] | 2 | `eps^(1/3) = 6.06e-6` | `eps^(2/3) = 3.67e-11` |
114//! | [`DiffScheme::Central4th`] | 4 | `eps^(1/5) = 7.40e-4` | `eps^(4/5) = 3.00e-13` |
115//!
116//! **A caller expecting `1e-15` from a finite difference will be wrong by seven
117//! orders of magnitude for a forward difference.** If you need machine
118//! precision, hand-code the derivative.
119//!
120//! *Measured.* `accuracy_floor_at_the_default_step` in `differentiate/tests.rs`,
121//! release, 2026-08-13. Worst relative error over six points in `[0.25, 3.3]`,
122//! each scheme at its own default step:
123//!
124//! | Function | forward | backward | central | central-4th |
125//! |---|---|---|---|---|
126//! | `sin` | 1.281191e-8 | 1.401018e-8 | 6.528067e-11 | 1.785239e-13 |
127//! | `exp` | 2.413003e-8 | 2.383524e-8 | 6.640831e-11 | 1.800352e-13 |
128//! | `x^3 - 2x` | 4.470348e-8 | 4.470348e-8 | 4.583556e-11 | 9.992007e-14 |
129//! | `1/(1 + x^2)` | 8.046627e-9 | 8.430242e-9 | 2.949979e-11 | 9.620083e-14 |
130//! | `tanh` | 6.775837e-9 | 8.125324e-9 | 1.244682e-11 | 1.963985e-13 |
131//! | **worst** | **4.470348e-8** | **4.470348e-8** | **6.640831e-11** | **1.963985e-13** |
132//! | *predicted* | *1.490116e-8* | *1.490116e-8* | *3.666853e-11* | *3.000214e-13* |
133//!
134//! Every scheme lands within a factor of three of its prediction, and the
135//! **ordering is exactly as predicted: central-4th beats central by 338x, and
136//! central beats forward by 673x.** The theory is a usable bound, not a story.
137//!
138//! *Observed convergence order.* `observed_convergence_order_matches_theory`,
139//! same run. Absolute error of `d/dx sin(x)` at `x = 1` (exact
140//! `5.40302305868139765e-1`) against the relative step:
141//!
142//! | Relative step | forward | backward | central | central-4th |
143//! |---|---|---|---|---|
144//! | `1e-1` | 4.293855e-2 | 4.113845e-2 | 9.000537e-4 | 1.125295e-7 |
145//! | `1e-2` | 4.216325e-3 | 4.198315e-3 | 9.004993e-6 | 1.126843e-11 |
146//! | `1e-3` | 4.208255e-4 | 4.206454e-4 | 9.005042e-8 | 1.049161e-13 |
147//! | `1e-4` | 4.207445e-5 | 4.207265e-5 | 9.003700e-10 | 3.312906e-13 |
148//! | **observed order** | **1.0079** | **0.9912** | **1.9998** | **3.9994** |
149//!
150//! The fourth-order column turning back upward at `1e-4` (1.05e-13 to 3.31e-13)
151//! is the round-off wall arriving, exactly where `eps^(1/5) = 7.4e-4` says it
152//! should.
153//!
154//! *The round-off wall.* `a_step_far_below_the_optimum_is_worse_not_better`,
155//! same run. Central difference, `d/dx sin(x)` at `x = 1`, relative error:
156//!
157//! | Relative step | Relative error |
158//! |---|---|
159//! | `1.0000e-2` | 1.666658e-5 |
160//! | `6.0555e-6` ([`CBRT_EPSILON`]) | **5.373555e-12** |
161//! | `1.0000e-8` | 5.303737e-9 |
162//! | `1.0000e-10` | 1.909780e-7 |
163//! | `1.0000e-12` | 5.609880e-5 |
164//! | `1.0000e-14` | 7.666335e-3 |
165//!
166//! **A step six million times smaller than the optimum is ten million times
167//! worse:** `1e-12` against `6.06e-6` moves the error from 5.373555e-12 to
168//! 5.609880e-5, a factor of 1.04e7. "Make `h` tiny for accuracy" is the intuition this table exists to
169//! destroy.
170//!
171//! # Forward against central: the cost/accuracy trade, measured
172//!
173//! For an `n`-dimensional Jacobian the evaluation counts are `n + 1`, `2n` and
174//! `4n` (see [`DiffScheme::evaluations_per_jacobian`]). The measured accuracy
175//! ratio from the table above is **673x** for one extra evaluation per column
176//! going forward to central, and a further **338x** for two more going to
177//! `Central4th`. Central is very nearly always the right default, which is why
178//! it is what [`DiffSettings::central`] exists for and what
179//! [`NumericalJacobian`]'s documentation recommends; [`DiffScheme::Forward`] is
180//! for the case where the function is genuinely expensive and `1e-8` is enough.
181//!
182//! # Verification against analytic Jacobians
183//!
184//! *Methodology.* Three systems whose Jacobians can be written down exactly are
185//! differenced and compared entry by entry: a **quadratic** system
186//! `[x0^2 + x1, x0*x1^2]`, a **trigonometric** system
187//! `[sin(x0)cos(x1), exp(x0)*x1]` in which no derivative of any order vanishes,
188//! and a **stiff linear pair** `[-1000*y0 + y1, y0 - y1]` with a 1000:1 entry
189//! spread. Pass criterion: worst relative error below `1e-7` for the first-order
190//! schemes and `1e-10`/`1e-11` for the higher-order ones. All four schemes pass
191//! on all three systems (release, 2026-08-13).
192//!
193//! *The stiff pair is the informative one*, because a **linear** system has
194//! exactly zero truncation error — so every digit lost is cancellation, and the
195//! measurement isolates it. Absolute error per entry at `y = [0.4, -0.9]`:
196//!
197//! | Scheme | `J[0][0]` (-1000) | `J[0][1]` (1) | `J[1][0]` (1) | `J[1][1]` (-1) |
198//! |---|---|---|---|---|
199//! | forward | 0 | 0 | 0 | 0 |
200//! | backward | 0 | 0 | 0 | 0 |
201//! | central | 6.600658e-10 | **1.778424e-9** | 9.167112e-12 | 1.833422e-11 |
202//! | central-4th | 5.456968e-12 | 6.380474e-11 | 2.498002e-14 | 0 |
203//!
204//! **`J[0][1]` is the worst entry by two orders of magnitude, and it is the
205//! small entry in the row that also holds `-1000`.** Row 0 evaluates to about
206//! `-400.9`; perturbing `x1` changes it by `1.2e-5`, so forming that difference
207//! discards eleven significant digits before the division. This is a **general
208//! property of finite-difference Jacobians, not of this implementation**: an
209//! entry is only as accurate as its own magnitude relative to the largest term
210//! in its row. A badly-scaled system loses precision in exactly the entries a
211//! stiff solver most needs. If that matters, hand-code the Jacobian or rescale
212//! the equations.
213//!
214//! The one-sided schemes returning **exactly zero** error here is a property of
215//! this particular linear case (`f(x+h) - f(x)` is exact when `f` is affine and
216//! the operands round identically) and must not be read as one-sided differences
217//! being more accurate in general — the accuracy table above shows them 673x
218//! *worse* on smooth non-linear functions.
219//!
220//! # Measured cost against a hand-coded Jacobian
221//!
222//! *Methodology.* Van der Pol `mu = 5`, `y(0) = [2, 0]`, integrated over
223//! `x` in `[0, 10]` by [`crate::ode::Rosenbrock23`] with `abs_tol = rel_tol =
224//! 1e-8`; best of 5, release, default features, the loaded 4-core machine
225//! described on [`DERIVATIVE_BATCH_MIN_POINTS`]. Van der Pol *has* an analytic
226//! Jacobian, which is the baseline. Produced by the `#[ignore]`d
227//! `numerical_jacobian_overhead_benchmark`, two runs.
228//!
229//! | Jacobian | Time (A) | Time (B) | vs analytic (A) | (B) | `y0(10)` |
230//! |---|---|---|---|---|---|
231//! | analytic (hand-coded) | 849.99 us | 855.47 us | 1.00x | 1.00x | -1.15870127 |
232//! | [`DiffScheme::Forward`] | 1619.03 us | 1603.88 us | 1.90x | 1.87x | -1.15870127 |
233//! | [`DiffScheme::Backward`] | 1607.45 us | 1614.33 us | 1.89x | 1.89x | -1.15870127 |
234//! | [`DiffScheme::Central`] | 1673.66 us | 1677.88 us | 1.97x | 1.96x | -1.15870127 |
235//! | [`DiffScheme::Central4th`] | 2487.96 us | 2455.24 us | 2.93x | 2.87x | -1.15870127 |
236//!
237//! **All four schemes reproduce the analytic result to all eight printed
238//! decimals**, at roughly twice the cost. That is the honest headline: a
239//! numerical Jacobian is not free, it is not exact, and for a `n = 2` stiff
240//! system it costs about a factor of two and changes nothing you can see.
241//!
242//! # A NaN Jacobian is NOT reported by the solver — check the counter
243//!
244//! When a Jacobian cannot be differenced, this module writes `NaN` into the
245//! entries and says so through [`DiffStatus`]. The natural expectation is that
246//! `Rosenbrock23` then fails loudly. **It does not, and this was measured
247//! rather than assumed** (`a_jacobian_that_cannot_be_differenced_is_counted_and_reaches_the_solver_as_nan`):
248//!
249//! - [`crate::ode::Rosenbrock23::integrate`] returns **`Ok(())`**;
250//! - the state vector comes back **`NaN`**.
251//!
252//! The cause is in the ODE layer, not here: `ode::normalize_error` folds the
253//! per-equation errors with `f64::max`, and `f64::max(0.0, NaN)` is `0.0` — so a
254//! `NaN` error estimate looks like a *perfectly converged* step and every
255//! sub-step is accepted. Nothing in this module can change that.
256//!
257//! **Consequence for callers:** [`NumericalJacobian::non_finite_jacobians`] is
258//! the only in-band signal that anything went wrong. Check it after any
259//! integration whose result you intend to trust. It is not decoration.
260//!
261//! # Hybrid means dispatch, not two APIs
262//!
263//! Every entry point takes a [`ComputeBackend`] parameter; there is no
264//! `_parallel()` sibling. With the `parallel` feature off,
265//! [`ComputeBackend::CpuMulti`] resolves down to [`ComputeBackend::Serial`] and
266//! the answer is unchanged — bit for bit, not merely close. There is no `Gpu`
267//! kernel here yet, so a `Gpu` request degrades to the best available CPU path.
268//!
269//! **Two independent parallel axes** live here, with separately measured
270//! crossovers 2048x apart:
271//!
272//! | Entry point | Parallel over | Crossover |
273//! |---|---|---|
274//! | [`derivative_batch`] | independent points | [`DERIVATIVE_BATCH_MIN_POINTS`] = 65 536 |
275//! | [`jacobian_batch`] | independent lanes | [`JACOBIAN_BATCH_MIN_PROBLEMS`] = 256 |
276//! | [`jacobian`] | the columns of **one** Jacobian | [`JACOBIAN_COLUMN_MIN_DIMENSION`] = 32 |
277//!
278//! They are not nested: [`jacobian_batch`] runs each lane's columns serially,
279//! because the lane axis is already saturating the pool.
280//!
281//! # Determinism — bitwise identical across backends and thread counts
282//!
283//! **This module returns bit-for-bit identical output on
284//! [`ComputeBackend::Serial`] and [`ComputeBackend::CpuMulti`], at any thread
285//! count, on every run**, provided the caller's function is a deterministic pure
286//! function of its arguments.
287//!
288//! The argument is the same one [`crate::math::minimise`] makes: lane `i`'s (or
289//! column `j`'s) answer is a pure function of its own samples, and **no
290//! arithmetic crosses lanes or columns**. A parallel *sum* would have to
291//! re-associate, and floating-point addition is not associative; a set of
292//! independent difference quotients has nothing to re-associate. Both backends
293//! call the same `#[inline]` per-lane and per-column kernels, and only the
294//! identity of the calling thread differs.
295//!
296//! Verified by the `bitwise_*` tests in `differentiate/tests.rs` on 2 048
297//! derivative lanes, 512 four-dimensional Jacobian lanes and one 96-dimensional
298//! Jacobian, all built with points spread over seven decades so the per-lane
299//! step differs. **Measured 2026-08-13 (release, `--features parallel`, 4
300//! logical cores): bit-identical on every observable field of every lane, for
301//! all four [`DiffScheme`] variants, at 1, 2, 4 and 8 workers.** The
302//! single-point [`derivative`] and single-lane [`jacobian`] forms are separately
303//! asserted bit-identical to their one-element batches.
304//!
305//! The `#[ignore]`d `differentiate_thread_scaling_benchmark` re-asserts the same
306//! identity while timing it, on 65 536 lanes with
307//! [`DiffScheme::Central4th`] (4 evaluations per lane), best of 7, two runs:
308//!
309//! | Worker threads | Time (A) | Speed-up (A) | (B) | Bitwise vs serial |
310//! |---|---|---|---|---|
311//! | *serial reference* | 5902.59 us | 1.00x | 1.00x | — |
312//! | 1 | 6008.47 us | 0.98x | 0.98x | identical |
313//! | 2 | 3181.82 us | 1.86x | 1.85x | identical |
314//! | 4 | 1582.50 us | 3.73x | 3.70x | identical |
315//! | 8 | 1577.90 us | 3.74x | 3.83x | identical |
316//!
317//! The "identical" column is asserted by the benchmark, not merely printed.
318//! Scaling is close to linear to 4 workers and flat beyond, which is what four
319//! logical cores should do. **The machine was not idle** (see
320//! [`DERIVATIVE_BATCH_MIN_POINTS`] for the load); one machine, one batch, two
321//! runs, nothing measured on Android hardware or a many-core server.
322//!
323//! The one way a caller can break this is to supply a function that is not pure
324//! — one that reads a random number generator, accumulates into shared
325//! interior-mutable state, or depends on the calling thread. The `Sync` bound
326//! permits it; this contract forbids it.
327//!
328//! # Failure is reported, never swallowed
329//!
330//! - Every lane and every Jacobian carries a [`DiffStatus`].
331//! - [`DerivativeSolution::derivative`] and [`JacobianSolution::matrix`] return
332//! `Option`, `Some` **only** on success. The diagnostic values are behind the
333//! deliberately-named [`DerivativeSolution::raw_value`] and
334//! [`JacobianSolution::raw_matrix`].
335//! - [`DerivativeBatch::values`] and [`JacobianBatch::matrices`] are
336//! all-or-nothing: they return [`DiffBatchFailure`] naming the failure count
337//! and the first failing lane, rather than a `Vec` with a `NaN` in it.
338//! - A failed Jacobian column is `NaN`, never `0.0`, and
339//! [`JacobianSolution::first_bad_column`] names the offending **variable**.
340//!
341//! **But read the limits of that guarantee** — the "What is detected, and what
342//! cannot be" section on [`derivative`] lists three classes of bad input that no
343//! finite-difference kernel can detect, chief among them a singularity that a
344//! symmetric stencil steps over.
345//!
346//! # What is deliberately NOT here: dual-number autodiff
347//!
348//! Bead `op-yvj.4.6` offers forward-mode dual numbers as an optional exact
349//! alternative, *"only if it stays simple"*. It is not implemented, on purpose.
350//! Making it useful means every function a caller wants differentiated must be
351//! generic over the scalar type, which would push a type parameter through
352//! `OdeSystem`, through the thermophysics kernels, and into every caller's own
353//! code. That is precisely the rise in reader context load the crate-level
354//! "Human interface layer" rule forbids, and the bead itself ranks that rule
355//! above the convenience. A caller who wants exact derivatives should hand-code
356//! them; that is what [`crate::ode::OdeSystem::jacobian`] is for.
357//!
358//! # Units
359//!
360//! Everything here is dimensionless `f64`, and that is a deliberate decision
361//! rather than `uom` being stripped.
362//!
363//! **A derivative changes dimension.** `d(enthalpy)/d(temperature)` is a heat
364//! capacity; `d(pressure)/d(volume)` is none of the three. A generic
365//! differentiator therefore has no single `uom` type it could return — the
366//! output type is a *function* of two input types, which Rust can only express
367//! through a trait with an associated output type, i.e. exactly the generic
368//! machinery the "Human interface layer" rule forbids adding for its own sake.
369//! The bead anticipates this and directs that, where the generic form cannot be
370//! typed cleanly, a small number of **concrete typed wrappers** is preferred
371//! over one generic nobody can read.
372//!
373//! So: `uom` typing is applied **at the boundary, by the caller** — convert in,
374//! convert out — exactly as [`crate::math::minimise`] and
375//! [`crate::math::parallel`] do. The one place a dimension does appear in this
376//! module's own API is [`DiffSettings::min_scale`], which carries the units of
377//! the variable being perturbed; its documentation says so, because a caller
378//! differentiating with respect to a pressure in pascals near zero must not
379//! leave it at `1.0`.
380//!
381//! # Cargo features and portability
382//!
383//! The `rayon` paths sit behind the crate's `parallel` feature, which is **off
384//! by default**; with it off this module still compiles and every entry point
385//! still works. `rayon` is pure Rust with no system component, so everything
386//! here compiles and runs on `aarch64-linux-android` / Termux exactly as on
387//! desktop. Nothing in this module is target-gated.
388//!
389//! # Example
390//!
391//! ```rust
392//! use outram_foam_basic_lib::math::differentiate::{DiffSettings, NumericalJacobian};
393//! use outram_foam_basic_lib::ode::{OdeSystem, Rosenbrock23};
394//!
395//! // A stiff system with NO hand-coded Jacobian. Without the wrapper the
396//! // default `OdeSystem::jacobian` would panic inside Rosenbrock23.
397//! struct StiffPair;
398//! impl OdeSystem for StiffPair {
399//! fn n_eqns(&self) -> usize { 2 }
400//! fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
401//! dydx.clear();
402//! dydx.push(-1000.0 * y[0] + y[1]);
403//! dydx.push(y[0] - y[1]);
404//! }
405//! }
406//!
407//! let system = NumericalJacobian::new(StiffPair, DiffSettings::central());
408//! let mut solver = Rosenbrock23::new(2, 1e-10, 1e-10);
409//! let mut y = vec![1.0_f64, 1.0];
410//! let mut dx = 1e-6;
411//! solver.integrate(&system, 0.0, 1.0, &mut y, &mut dx).expect("integrates");
412//!
413//! // The fast mode has decayed; the slow mode (eigenvalue about -0.999) remains.
414//! assert!(y[1].abs() < 1.0 && y[1] > 0.0, "y1 = {}", y[1]);
415//! // ALWAYS check this -- the solver does not report a NaN Jacobian itself.
416//! assert_eq!(system.non_finite_jacobians(), 0);
417//! ```
418
419#[cfg(feature = "parallel")]
420use rayon::prelude::*;
421
422use std::sync::atomic::{AtomicUsize, Ordering};
423
424use crate::compute::ComputeBackend;
425use crate::matrix::SquareMatrix;
426use crate::ode::OdeSystem;
427
428#[cfg(test)]
429mod tests;
430
431// ── Constants ────────────────────────────────────────────────────────────────
432
433/// `f64::EPSILON.cbrt()` = `6.0554544523933395e-6` — the relative step that
434/// balances truncation against round-off for a **central** difference.
435///
436/// A central difference has truncation error proportional to `h^2` (the
437/// coefficient is the third derivative over six) and round-off error
438/// proportional to `eps/h`; minimising their sum over `h` gives
439/// `h ~ eps^(1/3)`. The resulting accuracy is `~ eps^(2/3) = 3.67e-11`, **not**
440/// `eps` — see the module-level "Achievable accuracy" table for the measured
441/// value.
442///
443/// This is the same constant `outram-park-fork-offbeat`'s
444/// `rheology::aster::integration::perturbed_default()` returns, for exactly the
445/// same reason; see the module-level "Provenance" section.
446///
447/// # Units
448///
449/// Dimensionless — it multiplies a length scale in `x`.
450pub const CBRT_EPSILON: f64 = 6.055_454_452_393_339_5e-6;
451
452/// `f64::EPSILON.powf(0.2)` = `7.40095979741405e-4` — the relative step that
453/// balances truncation against round-off for the **fourth-order** Richardson
454/// scheme [`DiffScheme::Central4th`].
455///
456/// Truncation error proportional to `h^4`, round-off proportional to `eps/h`,
457/// so the balance is at `h ~ eps^(1/5)` and the accuracy is
458/// `~ eps^(4/5) = 3.00e-13`.
459///
460/// # Units
461///
462/// Dimensionless.
463pub const FIFTH_ROOT_EPSILON: f64 = 7.400_959_797_414_05e-4;
464
465/// Point count below which a [`ComputeBackend::CpuMulti`] request runs
466/// [`derivative_batch`] on the calling thread instead.
467///
468/// # Measured crossover
469///
470/// *Methodology.* Measured 2026-08-13 on this workspace's development machine,
471/// `std::thread::available_parallelism()` = **4**, release build,
472/// `--features parallel`, `rayon`'s global pool. **The machine was NOT idle:**
473/// 1-minute load average was 2.3-3.6 on 4 cores throughout, with a
474/// `bn daemon run` process holding a steady ~37% of one core. Batches of `n`
475/// points spread over seven decades of magnitude, [`DiffSettings::central`],
476/// best of 7 samples per point, wall clock for one whole batch. Produced by the
477/// `#[ignore]`d `differentiate_crossover_benchmark` test and transcribed from
478/// its printed output. `cheap` is a two-flop parabola; `costly` adds an
479/// `ln`/`exp`/`sqrt`/`tanh` chain, standing in for a property evaluation. Two
480/// independent runs are carried side by side rather than averaged, because the
481/// parallel column is far noisier than the serial one.
482///
483/// | Points | cheap serial | cheap speed-up (A) | (B) | costly serial | costly speed-up (A) | (B) |
484/// |---|---|---|---|---|---|---|
485/// | 16 | 0.18 us | 0.03x | 0.01x | 1.15 us | 0.14x | 0.04x |
486/// | 32 | 0.29 us | 0.03x | 0.01x | 2.11 us | 0.20x | 0.07x |
487/// | 64 | 0.51 us | 0.06x | 0.02x | 4.09 us | 0.39x | 0.13x |
488/// | 128 | 0.97 us | 0.10x | 0.04x | 8.07 us | 0.47x | 0.23x |
489/// | 256 | 1.87 us | 0.17x | 0.06x | 16.05 us | 0.71x | 0.40x |
490/// | 512 | 3.70 us | 0.27x | 0.11x | 31.85 us | 0.74x | 0.67x |
491/// | 1 024 | 7.33 us | 0.38x | 0.19x | 63.41 us | 0.85x | 1.12x |
492/// | 4 096 | 28.86 us | 0.56x | 0.52x | 253.71 us | 1.52x | 2.35x |
493/// | 16 384 | 115.53 us | 0.99x | 1.34x | 1025.92 us | 1.82x | 2.50x |
494/// | 65 536 | 484.26 us | **1.34x** | **1.35x** | 4224.20 us | 1.90x | 2.74x |
495///
496/// *Result.* **65 536** is the smallest size at which the cheap objective won in
497/// *both* runs, and it is the value this constant takes. That is 16x above the
498/// crate-wide [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`] placeholder and 256x
499/// above [`crate::math::minimise::MINIMISE_BATCH_MIN_PROBLEMS`], and the reason
500/// is structural rather than accidental: a scalar finite difference is **two to
501/// four evaluations of the caller's function and one division**, so with a cheap
502/// function the kernel is memory-bandwidth bound in exactly the way
503/// [`crate::fields::parallel`] is — and it lands within a factor of two of that
504/// module's independently measured 131 072. A batched root find, by contrast,
505/// runs *tens* of iterations per lane and crosses over at 256.
506///
507/// **The caller's function cost moves this by more than an order of magnitude.**
508/// The costly objective first wins at 1 024-4 096, sixteen to sixty-four times
509/// lower. A caller who knows its function is expensive should name
510/// [`ComputeBackend::CpuMulti`] explicitly rather than trust this number.
511///
512/// # Limitations
513///
514/// One machine, four logical cores, under load, one objective family. Not
515/// measured on Android/Termux hardware and not on a many-core server. The
516/// absolute timings should be read as ratios only.
517///
518/// # Units
519///
520/// A count of independent points, dimensionless.
521pub const DERIVATIVE_BATCH_MIN_POINTS: usize = 65_536;
522
523/// Lane count below which a [`ComputeBackend::CpuMulti`] request runs
524/// [`jacobian_batch`] on the calling thread instead.
525///
526/// # Measured crossover
527///
528/// *Methodology.* Same machine, date, build and load as
529/// [`DERIVATIVE_BATCH_MIN_POINTS`] — 4 logical cores, load average 2.3-3.6,
530/// **not idle**. `n = 4` Jacobians, [`DiffSettings::central`] so each lane costs
531/// 8 evaluations of a 4-component function, best of 7, two independent runs.
532/// Produced by the same `#[ignore]`d `differentiate_crossover_benchmark` test.
533///
534/// | Lanes | cheap serial | cheap speed-up (A) | (B) | costly speed-up (A) | (B) |
535/// |---|---|---|---|---|---|
536/// | 4 | 1.56 us | 0.19x | 0.16x | 0.21x | 0.21x |
537/// | 8 | 3.05 us | 0.10x | 0.12x | 0.63x | 0.35x |
538/// | 16 | 6.05 us | 0.19x | 0.21x | 0.60x | 0.65x |
539/// | 32 | 12.06 us | 0.83x | 0.33x | 1.52x | 1.03x |
540/// | 64 | 23.64 us | 0.57x | 0.70x | 1.18x | 2.18x |
541/// | 128 | 48.36 us | 0.84x | 1.34x | 1.86x | 2.46x |
542/// | 256 | 95.16 us | **1.09x** | **1.11x** | 1.88x | 2.61x |
543/// | 1 024 | 373.05 us | 1.63x | 2.18x | 1.86x | 1.50x |
544/// | 4 096 | 1590.74 us | 1.75x | 1.90x | 1.85x | 3.10x |
545/// | 16 384 | 6702.36 us | 1.83x | 2.98x | 2.06x | 2.88x |
546///
547/// *Result.* **256** — the smallest lane count that won in both runs. It lands
548/// on exactly the same value as
549/// [`crate::math::minimise::MINIMISE_BATCH_MIN_PROBLEMS`] and
550/// [`crate::math::parallel::ROOT_BATCH_MIN_PROBLEMS`], and 256x *below* this
551/// module's own [`DERIVATIVE_BATCH_MIN_POINTS`]. The two numbers in one module
552/// disagreeing by 256x is the clearest evidence yet for the point
553/// [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`] makes: the crossover tracks
554/// **work per lane**, not the algorithm. A Jacobian lane here does 8 function
555/// evaluations, four vector allocations and a matrix assembly; a scalar
556/// derivative lane does 2 evaluations and a division.
557///
558/// # A performance trap found by this measurement, recorded so it is not reintroduced
559///
560/// The first version of the central-difference column copied the point **twice**
561/// per column (`x.to_vec()` for the `+h` probe and again for the `-h` probe).
562/// With that version the parallel path **never won at any size measured**,
563/// topping out at 0.96x on 16 384 lanes — allocation traffic, not arithmetic,
564/// was the whole cost. Reusing a single probe buffer made the serial path ~1.4x
565/// faster *and* restored parallel scaling to 1.8-3.0x. If a future change
566/// reintroduces a per-column allocation, this crossover is the measurement that
567/// will notice.
568///
569/// # Limitations
570///
571/// As for [`DERIVATIVE_BATCH_MIN_POINTS`], plus: measured at `n = 4` only. A
572/// larger `n` raises the per-lane cost and should lower this crossover, but that
573/// has not been measured.
574///
575/// # Units
576///
577/// A count of independent Jacobian problems, dimensionless.
578pub const JACOBIAN_BATCH_MIN_PROBLEMS: usize = 256;
579
580/// Dimension below which [`jacobian`] computes one Jacobian's columns on the
581/// calling thread rather than spreading them across `rayon`.
582///
583/// This is the **other** parallel axis, and it is the one bead `op-yvj.4.6`
584/// names: an `n`-dimensional Jacobian's `n + 1` (or `2n`, or `4n`) evaluations
585/// are all independent.
586///
587/// # Measured crossover
588///
589/// *Methodology.* Same machine, date, build and load as
590/// [`DERIVATIVE_BATCH_MIN_POINTS`] — 4 logical cores, load average 2.3-2.9,
591/// **not idle**. One Jacobian of dimension `n`, [`DiffSettings::central`] so
592/// `2n` evaluations, of a residual whose every component sums over all `n`
593/// inputs — so one evaluation is `O(n^2)` and the whole Jacobian is `O(n^3)`,
594/// the shape a genuinely coupled residual has. Best of 7, two independent runs.
595/// Produced by the `#[ignore]`d `jacobian_column_crossover_benchmark` test.
596///
597/// | Dimension | `f` evals | serial | speed-up (A) | (B) |
598/// |---|---|---|---|---|
599/// | 4 | 8 | 0.72 us | 0.09x | 0.09x |
600/// | 8 | 16 | 3.03 us | 0.35x | 0.16x |
601/// | 16 | 32 | 15.93 us | 0.41x | 0.46x |
602/// | 32 | 64 | 103.63 us | **1.49x** | **1.53x** |
603/// | 64 | 128 | 767.15 us | 2.67x | 2.58x |
604/// | 128 | 256 | 6117.53 us | 1.98x | 2.55x |
605/// | 256 | 512 | 48189.43 us | 4.04x | 2.84x |
606/// | 512 | 1024 | 389479.48 us | 3.97x | 3.55x |
607///
608/// *Result.* **32** — the lowest dimension that won in both runs, and the
609/// smallest crossover anywhere in this crate. That is not surprising once the
610/// cost is written down: at `n = 32` a single Jacobian is already 64 evaluations
611/// of an `O(n^2)` residual, which is far more work per dispatch than 32 lanes of
612/// anything else in the crate.
613///
614/// **This crossover is even more caller-dependent than the others**, because it
615/// scales with the residual's own cost in `n`. A residual that is `O(1)` per
616/// component rather than `O(n)` will cross over much later. 32 is set for the
617/// coupled case; a caller with a cheap decoupled residual should pass
618/// [`ComputeBackend::Serial`] explicitly.
619///
620/// # Why an ODE Jacobian does not use this
621///
622/// [`ode_system_jacobian`] and [`NumericalJacobian`] always run their columns
623/// serially, whatever the dimension. An ODE system's `n` is its equation count —
624/// typically single or double digits, well under this threshold — and the
625/// parallel axis that matters for ODE work is the *ensemble lane*, which
626/// [`crate::ode::parallel::integrate_ensemble`] already provides. Nesting a
627/// `rayon` map inside that one would only contend for the same pool.
628///
629/// # Limitations
630///
631/// As for [`DERIVATIVE_BATCH_MIN_POINTS`], plus: one residual shape (`O(n)` per
632/// component). The 128 row losing ground to both its neighbours in run A
633/// (1.98x against 2.67x and 4.04x) is measurement noise on a loaded machine, not
634/// a real effect — do not read this table as a scaling study.
635///
636/// # Units
637///
638/// A count of Jacobian columns, i.e. the length of the point. Dimensionless.
639pub const JACOBIAN_COLUMN_MIN_DIMENSION: usize = 32;
640
641// ── Backend dispatch ─────────────────────────────────────────────────────────
642
643/// Resolve a requested backend to the one this module will actually run.
644///
645/// Three reductions, in order: [`ComputeBackend::resolve`] degrades anything
646/// whose feature is off; `Gpu` degrades again because this module has no GPU
647/// kernel yet; and `CpuMulti` degrades to `Serial` below `min_work_items`. The
648/// result is only ever `Serial` or `CpuMulti`, and none of the degradations can
649/// change a returned value.
650///
651/// Identical in shape to `minimise::effective_backend` and
652/// `parallel::effective_backend`; kept private and duplicated rather than
653/// hoisted because all three are four lines and hoisting would put a
654/// dispatch-policy helper in a module whose docs say it holds no kernels.
655fn effective_backend(
656 requested: ComputeBackend,
657 work_items: usize,
658 min_work_items: usize,
659) -> ComputeBackend {
660 let cpu = match requested.resolve() {
661 ComputeBackend::Gpu => ComputeBackend::CpuMulti.resolve(),
662 other => other,
663 };
664 match cpu {
665 ComputeBackend::CpuMulti if work_items >= min_work_items => ComputeBackend::CpuMulti,
666 _ => ComputeBackend::Serial,
667 }
668}
669
670/// The [`ComputeBackend`] [`derivative_batch`] would actually use for `n`
671/// points if asked for `requested` — without running anything.
672///
673/// Applies exactly the same reduction the kernel does (feature availability, no
674/// GPU kernel here, and the [`DERIVATIVE_BATCH_MIN_POINTS`] size floor), so what
675/// it reports is what would run.
676///
677/// # Arguments
678///
679/// - `requested` — the backend a caller would pass to [`derivative_batch`].
680/// - `n` — number of independent points in the batch, dimensionless.
681///
682/// # Returns
683///
684/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`]; never
685/// [`ComputeBackend::Gpu`], because no GPU kernel exists here yet.
686///
687/// # Example
688///
689/// ```rust
690/// use outram_foam_basic_lib::compute::ComputeBackend;
691/// use outram_foam_basic_lib::math::differentiate::{
692/// derivative_backend_for, DERIVATIVE_BATCH_MIN_POINTS,
693/// };
694///
695/// assert_eq!(
696/// derivative_backend_for(ComputeBackend::CpuMulti, 8),
697/// ComputeBackend::Serial
698/// );
699/// assert!(derivative_backend_for(ComputeBackend::CpuMulti, DERIVATIVE_BATCH_MIN_POINTS)
700/// .is_available());
701/// ```
702#[must_use]
703pub fn derivative_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
704 effective_backend(requested, n, DERIVATIVE_BATCH_MIN_POINTS)
705}
706
707/// The [`ComputeBackend`] [`jacobian_batch`] would actually use for `n`
708/// independent Jacobian problems — without running anything.
709///
710/// # Arguments
711///
712/// - `requested` — the backend a caller would pass to [`jacobian_batch`].
713/// - `n` — number of independent Jacobian problems (lanes), dimensionless.
714///
715/// # Returns
716///
717/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`].
718///
719/// # Example
720///
721/// ```rust
722/// use outram_foam_basic_lib::compute::ComputeBackend;
723/// use outram_foam_basic_lib::math::differentiate::jacobian_batch_backend_for;
724///
725/// assert_eq!(
726/// jacobian_batch_backend_for(ComputeBackend::CpuMulti, 2),
727/// ComputeBackend::Serial
728/// );
729/// ```
730#[must_use]
731pub fn jacobian_batch_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
732 effective_backend(requested, n, JACOBIAN_BATCH_MIN_PROBLEMS)
733}
734
735/// The [`ComputeBackend`] [`jacobian`] would actually use to spread the columns
736/// of **one** `dimension`-dimensional Jacobian — without running anything.
737///
738/// This is the *other* axis of parallelism in this module: [`jacobian_batch`]
739/// spreads independent problems across threads, while [`jacobian`] spreads the
740/// `n` independent column evaluations of a single problem.
741///
742/// # Arguments
743///
744/// - `requested` — the backend a caller would pass to [`jacobian`].
745/// - `dimension` — the length of the point `x`, i.e. the number of Jacobian
746/// columns. Dimensionless.
747///
748/// # Returns
749///
750/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`].
751///
752/// # Example
753///
754/// ```rust
755/// use outram_foam_basic_lib::compute::ComputeBackend;
756/// use outram_foam_basic_lib::math::differentiate::jacobian_column_backend_for;
757///
758/// // A 3-equation ODE Jacobian is never worth threading.
759/// assert_eq!(
760/// jacobian_column_backend_for(ComputeBackend::CpuMulti, 3),
761/// ComputeBackend::Serial
762/// );
763/// ```
764#[must_use]
765pub fn jacobian_column_backend_for(requested: ComputeBackend, dimension: usize) -> ComputeBackend {
766 effective_backend(requested, dimension, JACOBIAN_COLUMN_MIN_DIMENSION)
767}
768
769// ── Scheme, settings ─────────────────────────────────────────────────────────
770
771/// Which finite-difference stencil to use.
772///
773/// The choice is a **cost against accuracy** trade, and both halves are
774/// measured — see the module-level "Achievable accuracy" table for the observed
775/// error floors and "Cost" for the evaluation counts.
776///
777/// # Units
778///
779/// Dimensionless — a mode selector, not a quantity.
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
781pub enum DiffScheme {
782 /// `(f(x + h) - f(x)) / h`. Truncation error `O(h)`.
783 ///
784 /// The cheapest scheme for a Jacobian: the base evaluation `f(x)` is shared
785 /// by every column, so an `n`-dimensional Jacobian costs `n + 1`
786 /// evaluations rather than `2n`.
787 Forward,
788 /// `(f(x) - f(x - h)) / h`. Truncation error `O(h)`.
789 ///
790 /// Same cost and accuracy as [`Forward`](Self::Forward); it exists for
791 /// callers whose function is undefined or unphysical just *above* `x` — a
792 /// saturation pressure at the phase boundary, a volume fraction at 1.
793 Backward,
794 /// `(f(x + h) - f(x - h)) / (2h)`. Truncation error `O(h^2)`. **The
795 /// default.**
796 ///
797 /// This is the scheme both existing workspace implementations use — see the
798 /// module-level "Provenance" section.
799 Central,
800 /// Richardson extrapolation of two central differences, `(4*D(h/2) -
801 /// D(h)) / 3`. Truncation error `O(h^4)`.
802 ///
803 /// The most accurate scheme here and the most expensive: 4 evaluations per
804 /// derivative and `4n` per Jacobian, because the `h` and `h/2` stencils
805 /// share no points.
806 #[default]
807 Central4th,
808}
809
810impl DiffScheme {
811 /// The relative step size that balances truncation against round-off for
812 /// this scheme in `f64`.
813 ///
814 /// Truncation error goes as `h^p` and round-off as `eps/h`, so the balance
815 /// is at `h ~ eps^(1/(p+1))`:
816 ///
817 /// | Scheme | Order `p` | Optimal relative step |
818 /// |---|---|---|
819 /// | [`Forward`](Self::Forward), [`Backward`](Self::Backward) | 1 | [`crate::math::minimise::SQRT_EPSILON`] = `1.4901161193847656e-8` |
820 /// | [`Central`](Self::Central) | 2 | [`CBRT_EPSILON`] = `6.0554544523933395e-6` |
821 /// | [`Central4th`](Self::Central4th) | 4 | [`FIFTH_ROOT_EPSILON`] = `7.40095979741405e-4` |
822 ///
823 /// # Units
824 ///
825 /// Dimensionless — it multiplies a length scale in `x` to give a step in
826 /// `x`.
827 #[must_use]
828 pub fn default_relative_step(self) -> f64 {
829 match self {
830 Self::Forward | Self::Backward => crate::math::minimise::SQRT_EPSILON,
831 Self::Central => CBRT_EPSILON,
832 Self::Central4th => FIFTH_ROOT_EPSILON,
833 }
834 }
835
836 /// How many evaluations of the function one **scalar** derivative costs.
837 ///
838 /// # Units
839 ///
840 /// A count, dimensionless.
841 #[must_use]
842 pub fn evaluations_per_derivative(self) -> usize {
843 match self {
844 Self::Forward | Self::Backward | Self::Central => 2,
845 Self::Central4th => 4,
846 }
847 }
848
849 /// How many evaluations of the vector function an `n`-dimensional Jacobian
850 /// costs.
851 ///
852 /// [`Forward`](Self::Forward) and [`Backward`](Self::Backward) get `n + 1`
853 /// because the unperturbed evaluation is shared across all `n` columns;
854 /// [`Central`](Self::Central) gets `2n` and [`Central4th`](Self::Central4th)
855 /// `4n` because their stencils are symmetric about `x` and so share nothing.
856 ///
857 /// # Arguments
858 ///
859 /// - `n` — the dimension of the point, dimensionless.
860 ///
861 /// # Units
862 ///
863 /// A count, dimensionless.
864 ///
865 /// # Example
866 ///
867 /// ```rust
868 /// use outram_foam_basic_lib::math::differentiate::DiffScheme;
869 ///
870 /// assert_eq!(DiffScheme::Forward.evaluations_per_jacobian(10), 11);
871 /// assert_eq!(DiffScheme::Central.evaluations_per_jacobian(10), 20);
872 /// assert_eq!(DiffScheme::Central4th.evaluations_per_jacobian(10), 40);
873 /// ```
874 #[must_use]
875 pub fn evaluations_per_jacobian(self, n: usize) -> usize {
876 match self {
877 Self::Forward | Self::Backward => n + 1,
878 Self::Central => 2 * n,
879 Self::Central4th => 4 * n,
880 }
881 }
882
883 /// A short human-readable label, for benchmark tables and log lines.
884 #[must_use]
885 pub fn label(self) -> &'static str {
886 match self {
887 Self::Forward => "forward",
888 Self::Backward => "backward",
889 Self::Central => "central",
890 Self::Central4th => "central-4th",
891 }
892 }
893}
894
895/// Step-size policy for every entry point in this module.
896///
897/// # The step-size rule
898///
899/// ```text
900/// h = relative_step * max(|x|, min_scale)
901/// ```
902///
903/// The step is **relative to the magnitude of the variable being perturbed**,
904/// because a step that is right for `x ~ 1` is far too small for a pressure in
905/// pascals and far too large for a mole fraction. `min_scale` is the floor that
906/// keeps the rule usable at `x = 0` — see [`Self::step_for`].
907///
908/// # Units
909///
910/// `relative_step` is dimensionless. `min_scale` carries the **same units as
911/// the variable being differentiated with respect to**, because it is a
912/// fallback magnitude for `x`, and its default of `1.0` therefore means "one of
913/// whatever unit `x` is in". A caller differentiating with respect to a
914/// pressure in pascals near zero wants `min_scale` set to a pascal-scale
915/// number, not `1.0`.
916#[derive(Debug, Clone, Copy, PartialEq)]
917pub struct DiffSettings {
918 /// Which stencil to use. See [`DiffScheme`].
919 pub scheme: DiffScheme,
920 /// The relative step, dimensionless. Defaults to
921 /// [`DiffScheme::default_relative_step`] for the chosen scheme.
922 pub relative_step: f64,
923 /// Floor on `|x|` in the step rule, so `x = 0` still gets a usable step.
924 /// Same units as `x`. Default `1.0`.
925 pub min_scale: f64,
926}
927
928impl Default for DiffSettings {
929 /// [`DiffScheme::Central4th`] with its optimal relative step and
930 /// `min_scale = 1.0`.
931 ///
932 /// The default is the *most accurate* scheme rather than the cheapest,
933 /// because a caller who has not thought about step size is far more likely
934 /// to be surprised by a wrong derivative than by four function evaluations.
935 fn default() -> Self {
936 Self::with_scheme(DiffScheme::Central4th)
937 }
938}
939
940impl DiffSettings {
941 /// Settings for `scheme`, with that scheme's optimal relative step and
942 /// `min_scale = 1.0`.
943 ///
944 /// # Example
945 ///
946 /// ```rust
947 /// use outram_foam_basic_lib::math::differentiate::{DiffScheme, DiffSettings};
948 ///
949 /// let s = DiffSettings::with_scheme(DiffScheme::Central);
950 /// assert_eq!(s.relative_step, DiffScheme::Central.default_relative_step());
951 /// assert_eq!(s.min_scale, 1.0);
952 /// ```
953 #[must_use]
954 pub fn with_scheme(scheme: DiffScheme) -> Self {
955 Self {
956 scheme,
957 relative_step: scheme.default_relative_step(),
958 min_scale: 1.0,
959 }
960 }
961
962 /// [`DiffScheme::Forward`] with its optimal relative step — the `n + 1`
963 /// evaluation Jacobian.
964 #[must_use]
965 pub fn forward() -> Self {
966 Self::with_scheme(DiffScheme::Forward)
967 }
968
969 /// [`DiffScheme::Backward`] with its optimal relative step.
970 #[must_use]
971 pub fn backward() -> Self {
972 Self::with_scheme(DiffScheme::Backward)
973 }
974
975 /// [`DiffScheme::Central`] with its optimal relative step — the `2n`
976 /// evaluation Jacobian, and the scheme both existing workspace
977 /// implementations use.
978 #[must_use]
979 pub fn central() -> Self {
980 Self::with_scheme(DiffScheme::Central)
981 }
982
983 /// [`DiffScheme::Central4th`] with its optimal relative step — the `4n`
984 /// evaluation Jacobian. Same as [`Self::default`].
985 #[must_use]
986 pub fn central_4th() -> Self {
987 Self::with_scheme(DiffScheme::Central4th)
988 }
989
990 /// The step this policy uses to perturb a variable currently at `x`.
991 ///
992 /// ```text
993 /// h = relative_step * max(|x|, min_scale)
994 /// ```
995 ///
996 /// # What happens at `x = 0`
997 ///
998 /// A purely relative step `relative_step * |x|` is **exactly zero** at
999 /// `x = 0`, which would divide by zero and hand back `NaN` or `inf`. The
1000 /// `max(|x|, min_scale)` floor is what prevents that: at `x = 0` the step
1001 /// becomes `relative_step * min_scale`, i.e. an *absolute* step of
1002 /// `relative_step` in the default `min_scale = 1.0` case.
1003 ///
1004 /// This is the convention already settled elsewhere in this workspace —
1005 /// `outram-park-fork-offbeat`'s `newton_perturbed` uses
1006 /// `perturbation * x.abs().max(1.0)` for exactly this reason. It is
1007 /// **not** the convention `outram-park-fork-dwsim-libs`'
1008 /// `finite_difference_jacobian` uses; see the module-level "Provenance"
1009 /// section for that divergence and why.
1010 ///
1011 /// The same floor also rescues the near-zero case, which is the one that
1012 /// actually bites: at `x = 1e-300` a relative step is `1e-308`-ish, so
1013 /// `x + h` rounds straight back to `x` and the difference is identically
1014 /// zero. That is reported as [`DiffStatus::DegenerateStep`], not as a
1015 /// derivative of zero.
1016 ///
1017 /// # Returns
1018 ///
1019 /// The step, in the same units as `x`. Non-finite or non-positive results
1020 /// are possible if the caller supplies a nonsensical `relative_step`, and
1021 /// are caught by the kernels rather than by this function.
1022 ///
1023 /// # Example
1024 ///
1025 /// ```rust
1026 /// use outram_foam_basic_lib::math::differentiate::DiffSettings;
1027 ///
1028 /// let s = DiffSettings::central();
1029 /// // Relative where |x| is large ...
1030 /// assert_eq!(s.step_for(1000.0), s.relative_step * 1000.0);
1031 /// // ... absolute where it is not, so x = 0 still works.
1032 /// assert_eq!(s.step_for(0.0), s.relative_step);
1033 /// assert_eq!(s.step_for(-1000.0), s.relative_step * 1000.0);
1034 /// ```
1035 #[must_use]
1036 pub fn step_for(&self, x: f64) -> f64 {
1037 self.relative_step * x.abs().max(self.min_scale)
1038 }
1039}
1040
1041// ── Status ───────────────────────────────────────────────────────────────────
1042
1043/// Why a derivative or Jacobian entry is, or is not, trustworthy.
1044///
1045/// # Units
1046///
1047/// Dimensionless — a status code.
1048#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1049pub enum DiffStatus {
1050 /// The difference quotient was formed from finite evaluations with a
1051 /// non-degenerate step. The value is usable.
1052 Ok,
1053 /// The point `x` itself was not finite, so no step could be taken.
1054 InvalidPoint,
1055 /// At least one function evaluation returned a non-finite value, or the
1056 /// difference quotient itself came out non-finite (overflow in the
1057 /// subtraction, for instance).
1058 NotFinite,
1059 /// The step collapsed: `relative_step` was zero, negative or non-finite, or
1060 /// `x + h` rounded back to `x` so the realised step was exactly zero. The
1061 /// quotient would have been a division by zero.
1062 DegenerateStep,
1063 /// The vector function returned a different number of components than the
1064 /// point has, so the Jacobian is not square and cannot be assembled.
1065 ///
1066 /// Only reachable from [`jacobian`] and its batched form; the square
1067 /// restriction is documented on [`jacobian`].
1068 DimensionMismatch,
1069}
1070
1071impl DiffStatus {
1072 /// Whether the value this status accompanies may be used.
1073 #[must_use]
1074 pub fn is_ok(self) -> bool {
1075 matches!(self, Self::Ok)
1076 }
1077
1078 /// A short human-readable label, for log lines and benchmark tables.
1079 #[must_use]
1080 pub fn label(self) -> &'static str {
1081 match self {
1082 Self::Ok => "ok",
1083 Self::InvalidPoint => "invalid-point",
1084 Self::NotFinite => "not-finite",
1085 Self::DegenerateStep => "degenerate-step",
1086 Self::DimensionMismatch => "dimension-mismatch",
1087 }
1088 }
1089}
1090
1091// ── Scalar derivative ────────────────────────────────────────────────────────
1092
1093/// One lane's scalar derivative, with the diagnostics needed to judge it.
1094///
1095/// # Units
1096///
1097/// [`Self::raw_value`] carries the units of `f` divided by the units of `x`
1098/// — a derivative changes dimension, which is exactly why this module does not
1099/// try to `uom`-type the generic form. See the module-level "Units" section.
1100/// [`realised_step`](Self::realised_step) carries the units of `x`.
1101#[derive(Debug, Clone, Copy, PartialEq)]
1102pub struct DerivativeSolution {
1103 value: f64,
1104 realised_step: f64,
1105 status: DiffStatus,
1106}
1107
1108impl DerivativeSolution {
1109 /// The derivative, **only if** this lane succeeded.
1110 ///
1111 /// Returns `None` for every non-[`DiffStatus::Ok`] status, so a caller
1112 /// cannot accidentally consume a `NaN` as a derivative. The diagnostic
1113 /// number is behind the deliberately-named [`Self::raw_value`].
1114 #[must_use]
1115 pub fn derivative(&self) -> Option<f64> {
1116 if self.status.is_ok() {
1117 Some(self.value)
1118 } else {
1119 None
1120 }
1121 }
1122
1123 /// The difference quotient as computed, whatever the status — a diagnostic,
1124 /// **not** an answer. Frequently `NaN`.
1125 #[must_use]
1126 pub fn raw_value(&self) -> f64 {
1127 self.value
1128 }
1129
1130 /// The step actually taken, after the `x + h` rounding correction described
1131 /// on [`derivative`]. Units of `x`.
1132 ///
1133 /// This is the denominator that was really divided by, not the `h` that
1134 /// [`DiffSettings::step_for`] asked for, and comparing the two is the
1135 /// cheapest way to see step-size trouble.
1136 #[must_use]
1137 pub fn realised_step(&self) -> f64 {
1138 self.realised_step
1139 }
1140
1141 /// Why this lane succeeded or failed.
1142 #[must_use]
1143 pub fn status(&self) -> DiffStatus {
1144 self.status
1145 }
1146
1147 /// Whether this lane produced a usable derivative.
1148 #[must_use]
1149 pub fn is_ok(&self) -> bool {
1150 self.status.is_ok()
1151 }
1152
1153 fn ok(value: f64, realised_step: f64) -> Self {
1154 Self {
1155 value,
1156 realised_step,
1157 status: DiffStatus::Ok,
1158 }
1159 }
1160
1161 fn failed(status: DiffStatus, realised_step: f64) -> Self {
1162 Self {
1163 value: f64::NAN,
1164 realised_step,
1165 status,
1166 }
1167 }
1168}
1169
1170/// One or more lanes of a [`DerivativeBatch`] or [`JacobianBatch`] failed.
1171///
1172/// Returned by the all-or-nothing accessors [`DerivativeBatch::values`] and
1173/// [`JacobianBatch::matrices`]. It names both the scale of the problem (how
1174/// many of how many) and a specific lane to look at, because "3 of 10 000 lanes
1175/// failed" is only actionable once you know *which* lane.
1176///
1177/// # Units
1178///
1179/// All counts and indices are dimensionless.
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1181#[error(
1182 "{failure_count} of {total} differentiation lanes failed; \
1183 first failure at lane {first_index} with status {first_status:?}"
1184)]
1185pub struct DiffBatchFailure {
1186 /// Number of lanes in the batch.
1187 pub total: usize,
1188 /// Number of lanes that failed.
1189 pub failure_count: usize,
1190 /// Index of the first failing lane.
1191 pub first_index: usize,
1192 /// Why that lane failed.
1193 pub first_status: DiffStatus,
1194}
1195
1196/// The result of [`derivative_batch`] — one [`DerivativeSolution`] per point,
1197/// in point order.
1198#[derive(Debug, Clone, PartialEq)]
1199pub struct DerivativeBatch {
1200 solutions: Vec<DerivativeSolution>,
1201}
1202
1203impl DerivativeBatch {
1204 /// Every lane's solution, in the order the points were supplied.
1205 #[must_use]
1206 pub fn solutions(&self) -> &[DerivativeSolution] {
1207 &self.solutions
1208 }
1209
1210 /// Consume the batch, yielding the per-lane solutions.
1211 #[must_use]
1212 pub fn into_solutions(self) -> Vec<DerivativeSolution> {
1213 self.solutions
1214 }
1215
1216 /// Number of lanes.
1217 #[must_use]
1218 pub fn len(&self) -> usize {
1219 self.solutions.len()
1220 }
1221
1222 /// Whether the batch has no lanes.
1223 #[must_use]
1224 pub fn is_empty(&self) -> bool {
1225 self.solutions.is_empty()
1226 }
1227
1228 /// Lane `i`'s solution, or `None` if `i` is out of range.
1229 #[must_use]
1230 pub fn get(&self, i: usize) -> Option<DerivativeSolution> {
1231 self.solutions.get(i).copied()
1232 }
1233
1234 /// Whether every lane produced a usable derivative.
1235 #[must_use]
1236 pub fn all_ok(&self) -> bool {
1237 self.solutions.iter().all(DerivativeSolution::is_ok)
1238 }
1239
1240 /// How many lanes failed.
1241 #[must_use]
1242 pub fn failure_count(&self) -> usize {
1243 self.solutions.iter().filter(|s| !s.is_ok()).count()
1244 }
1245
1246 /// The first failing lane and its solution, if any.
1247 #[must_use]
1248 pub fn first_failure(&self) -> Option<(usize, DerivativeSolution)> {
1249 self.solutions
1250 .iter()
1251 .enumerate()
1252 .find(|(_, s)| !s.is_ok())
1253 .map(|(i, s)| (i, *s))
1254 }
1255
1256 /// Every failing lane and its solution.
1257 #[must_use]
1258 pub fn failures(&self) -> Vec<(usize, DerivativeSolution)> {
1259 self.solutions
1260 .iter()
1261 .enumerate()
1262 .filter(|(_, s)| !s.is_ok())
1263 .map(|(i, s)| (i, *s))
1264 .collect()
1265 }
1266
1267 /// Every lane's derivative, **all or nothing**.
1268 ///
1269 /// # Errors
1270 ///
1271 /// [`DiffBatchFailure`] naming the failure count and the first failing lane
1272 /// if any lane failed. A partially-`NaN` `Vec<f64>` is never returned.
1273 pub fn values(&self) -> Result<Vec<f64>, DiffBatchFailure> {
1274 self.check_all_ok()?;
1275 Ok(self.solutions.iter().map(|s| s.value).collect())
1276 }
1277
1278 /// `Err` describing the first failure, if any lane failed.
1279 ///
1280 /// # Errors
1281 ///
1282 /// [`DiffBatchFailure`] as for [`Self::values`].
1283 pub fn check_all_ok(&self) -> Result<(), DiffBatchFailure> {
1284 if let Some((i, s)) = self.first_failure() {
1285 return Err(DiffBatchFailure {
1286 total: self.solutions.len(),
1287 failure_count: self.failure_count(),
1288 first_index: i,
1289 first_status: s.status(),
1290 });
1291 }
1292 Ok(())
1293 }
1294}
1295
1296/// Differentiate one scalar function at one point.
1297///
1298/// The single-lane form of [`derivative_batch`], for callers with one
1299/// derivative to take. It runs on the calling thread — there is nothing to
1300/// spread — and calls the *same* per-lane kernel, so it agrees with a
1301/// one-element batch bit for bit.
1302///
1303/// # The realised-step correction
1304///
1305/// `x + h` is generally not representable, so the value the machine actually
1306/// evaluates at differs from `x + h` in the last bits and the true step is not
1307/// `h`. This kernel therefore evaluates at `xp = x + h` and divides by
1308/// `xp - x`, which **is** exact, rather than by `h`. The device is from
1309/// *Numerical Recipes* (Press et al., 3rd ed., section 5.7) and it removes an
1310/// error source that would otherwise be comparable to the round-off term the
1311/// step rule is trying to balance. [`DerivativeSolution::realised_step`]
1312/// reports the corrected denominator.
1313///
1314/// # Arguments
1315///
1316/// - `x` — the point, in the caller's own units.
1317/// - `settings` — scheme and step-size policy; see [`DiffSettings`].
1318/// - `f` — the function. Units of the return value are the caller's.
1319///
1320/// # Returns
1321///
1322/// A [`DerivativeSolution`] whose [`derivative`](DerivativeSolution::derivative)
1323/// is `Some` only if every evaluation was finite and the realised step was
1324/// non-zero. **That is the whole of the guarantee** — read the next section
1325/// before relying on it.
1326///
1327/// # What is detected, and what cannot be
1328///
1329/// The status is computed from exactly one predicate — every sampled value and
1330/// the resulting quotient are finite, and the realised step is non-zero — and
1331/// [`DerivativeSolution::derivative`] returns `Some` on exactly that same
1332/// predicate. They cannot disagree.
1333///
1334/// **Detected:** a non-finite sample ([`DiffStatus::NotFinite`]), a non-finite
1335/// point ([`DiffStatus::InvalidPoint`]), and a step that rounds away so
1336/// `x + h == x` ([`DiffStatus::DegenerateStep`]).
1337///
1338/// **Not detected, and not detectable by any finite-difference kernel:**
1339///
1340/// - **A singularity the stencil steps over.** `1/x` at `x = 0` is sampled by
1341/// the central stencil at `+h` and `-h`, both perfectly finite, so it returns
1342/// `1/h^2` with [`DiffStatus::Ok`]. The kernel never evaluates at the pole and
1343/// has no way to learn it is there. A one-sided scheme *does* see this
1344/// particular case, because it evaluates at `x` itself — but it has the
1345/// mirror-image blind spot on the other side.
1346/// - **Cancellation that leaves a finite number with no correct digits.** The
1347/// quotient is a perfectly ordinary `f64`; nothing about it says how many of
1348/// its bits survived. This is what the step-size rule exists to bound, and why
1349/// the module documents an *accuracy floor* rather than a guarantee.
1350/// - **A function that is not differentiable at `x`.** `|x|` at `0` returns `0`
1351/// from the central stencil, confidently.
1352///
1353/// If the function may be singular or kinked, bracket it away from the trouble
1354/// or check the result against a second scheme; the status field will not do it
1355/// for you.
1356///
1357/// # Example
1358///
1359/// ```rust
1360/// use outram_foam_basic_lib::math::differentiate::{
1361/// derivative, DiffSettings, DiffStatus,
1362/// };
1363///
1364/// // d/dx sin(x) at x = 1 is cos(1).
1365/// let s = derivative(1.0, DiffSettings::central(), |x: f64| x.sin());
1366/// let d = s.derivative().expect("finite everywhere");
1367/// assert!((d - 1.0_f64.cos()).abs() < 1e-10, "got {d}");
1368///
1369/// // A sample that comes back non-finite IS reported: the central stencil for
1370/// // sqrt at x = 0 evaluates at -h, which is NaN.
1371/// let bad = derivative(0.0, DiffSettings::central(), |x: f64| x.sqrt());
1372/// assert_eq!(bad.status(), DiffStatus::NotFinite);
1373/// assert!(bad.derivative().is_none());
1374///
1375/// // But a pole the stencil STEPS OVER is not, and cannot be -- see
1376/// // "What is detected, and what cannot be" above.
1377/// let undetected = derivative(0.0, DiffSettings::central(), |x: f64| 1.0 / x);
1378/// assert_eq!(undetected.status(), DiffStatus::Ok);
1379/// assert!(undetected.derivative().is_some());
1380/// ```
1381#[must_use]
1382pub fn derivative<F>(x: f64, settings: DiffSettings, f: F) -> DerivativeSolution
1383where
1384 F: Fn(f64) -> f64,
1385{
1386 derivative_one(0, x, settings, &|_, t| f(t))
1387}
1388
1389/// Differentiate `N` independent scalar functions at `N` points, on the chosen
1390/// backend.
1391///
1392/// This is the batched, GPU-shaped form: lane `i` differentiates `f(i, .)` at
1393/// `points[i]`, and no arithmetic crosses lanes.
1394///
1395/// # Arguments
1396///
1397/// - `points` — one abscissa per lane, in the caller's own units.
1398/// - `settings` — scheme and step-size policy, shared by every lane.
1399/// - `backend` — requested execution backend. What actually runs is
1400/// [`derivative_backend_for`] applied to it. **None of the degradations
1401/// changes the answer.**
1402/// - `f` — `f(i, x)` is lane `i`'s function evaluated at `x`. It **must be a
1403/// pure deterministic function of its arguments** — see the module-level
1404/// "Determinism" section. The `Sync` bound is present in both feature builds
1405/// so enabling `parallel` never changes a public signature.
1406///
1407/// # Returns
1408///
1409/// A [`DerivativeBatch`] with one solution per point, in point order. An empty
1410/// `points` slice returns an empty batch and calls `f` zero times.
1411///
1412/// # Cost
1413///
1414/// [`DiffScheme::evaluations_per_derivative`] calls to `f` per lane — 2 for the
1415/// three second-order-or-lower schemes, 4 for [`DiffScheme::Central4th`].
1416///
1417/// # Example
1418///
1419/// ```rust
1420/// use outram_foam_basic_lib::compute::ComputeBackend;
1421/// use outram_foam_basic_lib::math::differentiate::{
1422/// derivative_batch, DiffSettings,
1423/// };
1424///
1425/// // Lane i differentiates x^(i+1) at x = 2; the answer is (i+1) * 2^i.
1426/// let points = vec![2.0_f64; 4];
1427/// let batch = derivative_batch(
1428/// &points,
1429/// DiffSettings::central(),
1430/// ComputeBackend::CpuMulti,
1431/// |i, x: f64| x.powi(i as i32 + 1),
1432/// );
1433///
1434/// let d = batch.values().expect("all lanes finite");
1435/// for (i, got) in d.iter().enumerate() {
1436/// let want = (i as f64 + 1.0) * 2.0_f64.powi(i as i32);
1437/// assert!((got - want).abs() < 1e-6 * want.abs().max(1.0), "lane {i}: {got} vs {want}");
1438/// }
1439/// ```
1440#[must_use]
1441pub fn derivative_batch<F>(
1442 points: &[f64],
1443 settings: DiffSettings,
1444 backend: ComputeBackend,
1445 f: F,
1446) -> DerivativeBatch
1447where
1448 F: Fn(usize, f64) -> f64 + Sync,
1449{
1450 derivative_batch_min(points, settings, backend, DERIVATIVE_BATCH_MIN_POINTS, f)
1451}
1452
1453/// [`derivative_batch`] with the size floor supplied by the caller.
1454///
1455/// Exists so the crossover benchmark can measure the multi-CPU path *below*
1456/// [`DERIVATIVE_BATCH_MIN_POINTS`] — the only way to find where the crossover
1457/// actually is — and so the cross-backend bitwise tests are not vacuous on
1458/// small batches. Not public: production callers get the measured floor.
1459pub(crate) fn derivative_batch_min<F>(
1460 points: &[f64],
1461 settings: DiffSettings,
1462 backend: ComputeBackend,
1463 min_points: usize,
1464 f: F,
1465) -> DerivativeBatch
1466where
1467 F: Fn(usize, f64) -> f64 + Sync,
1468{
1469 let n = points.len();
1470 let solutions: Vec<DerivativeSolution> = match effective_backend(backend, n, min_points) {
1471 #[cfg(feature = "parallel")]
1472 ComputeBackend::CpuMulti => points
1473 .par_iter()
1474 .enumerate()
1475 .map(|(i, &x)| derivative_one(i, x, settings, &f))
1476 .collect(),
1477 _ => points
1478 .iter()
1479 .enumerate()
1480 .map(|(i, &x)| derivative_one(i, x, settings, &f))
1481 .collect(),
1482 };
1483 DerivativeBatch { solutions }
1484}
1485
1486// ── Per-lane scalar kernel — one implementation, both backends ───────────────
1487
1488/// The single-lane finite difference that **both** backends run.
1489///
1490/// `#[inline]` so the serial loop and the `rayon` map compile to the same inner
1491/// code — part of why the two backends agree bit for bit.
1492#[inline]
1493fn derivative_one<F>(i: usize, x: f64, settings: DiffSettings, f: &F) -> DerivativeSolution
1494where
1495 F: Fn(usize, f64) -> f64,
1496{
1497 if !x.is_finite() {
1498 return DerivativeSolution::failed(DiffStatus::InvalidPoint, f64::NAN);
1499 }
1500 let h = settings.step_for(x);
1501 if !h.is_finite() || h <= 0.0 {
1502 return DerivativeSolution::failed(DiffStatus::DegenerateStep, h);
1503 }
1504
1505 match settings.scheme {
1506 DiffScheme::Forward => one_sided(i, x, h, f),
1507 DiffScheme::Backward => one_sided(i, x, -h, f),
1508 DiffScheme::Central => central(i, x, h, f),
1509 DiffScheme::Central4th => {
1510 // Richardson extrapolation of two central differences.
1511 // D(h) has error c2*h^2 + c4*h^4 + ...; D(h/2) has c2*h^2/4 + ...
1512 // so (4*D(h/2) - D(h)) / 3 cancels the h^2 term exactly.
1513 let coarse = central(i, x, h, f);
1514 if !coarse.is_ok() {
1515 return coarse;
1516 }
1517 let fine = central(i, x, 0.5 * h, f);
1518 if !fine.is_ok() {
1519 return fine;
1520 }
1521 let value = (4.0 * fine.value - coarse.value) / 3.0;
1522 if value.is_finite() {
1523 DerivativeSolution::ok(value, fine.realised_step)
1524 } else {
1525 DerivativeSolution::failed(DiffStatus::NotFinite, fine.realised_step)
1526 }
1527 }
1528 }
1529}
1530
1531/// Forward (`h > 0`) or backward (`h < 0`) difference at `x`.
1532#[inline]
1533fn one_sided<F>(i: usize, x: f64, h: f64, f: &F) -> DerivativeSolution
1534where
1535 F: Fn(usize, f64) -> f64,
1536{
1537 let xp = x + h;
1538 // `xp - x` is exact and is the step the machine really took; `h` is not.
1539 let dh = xp - x;
1540 if dh == 0.0 || !dh.is_finite() {
1541 return DerivativeSolution::failed(DiffStatus::DegenerateStep, dh);
1542 }
1543 let f0 = f(i, x);
1544 let f1 = f(i, xp);
1545 if !f0.is_finite() || !f1.is_finite() {
1546 return DerivativeSolution::failed(DiffStatus::NotFinite, dh);
1547 }
1548 let value = (f1 - f0) / dh;
1549 if value.is_finite() {
1550 DerivativeSolution::ok(value, dh)
1551 } else {
1552 DerivativeSolution::failed(DiffStatus::NotFinite, dh)
1553 }
1554}
1555
1556/// Central difference at `x` with half-width `h`.
1557#[inline]
1558fn central<F>(i: usize, x: f64, h: f64, f: &F) -> DerivativeSolution
1559where
1560 F: Fn(usize, f64) -> f64,
1561{
1562 let xp = x + h;
1563 let xm = x - h;
1564 let dh = xp - xm;
1565 if dh == 0.0 || !dh.is_finite() {
1566 return DerivativeSolution::failed(DiffStatus::DegenerateStep, dh);
1567 }
1568 let fp = f(i, xp);
1569 let fm = f(i, xm);
1570 if !fp.is_finite() || !fm.is_finite() {
1571 return DerivativeSolution::failed(DiffStatus::NotFinite, dh);
1572 }
1573 let value = (fp - fm) / dh;
1574 if value.is_finite() {
1575 DerivativeSolution::ok(value, dh)
1576 } else {
1577 DerivativeSolution::failed(DiffStatus::NotFinite, dh)
1578 }
1579}
1580
1581// ── Jacobians ────────────────────────────────────────────────────────────────
1582
1583/// One lane's Jacobian, with the status needed to judge it.
1584///
1585/// # Units
1586///
1587/// Entry `(i, j)` of the matrix carries the units of `f_i` divided by the units
1588/// of `x_j`. See the module-level "Units" section for why the generic form is
1589/// not `uom`-typed.
1590#[derive(Debug, Clone)]
1591pub struct JacobianSolution {
1592 matrix: SquareMatrix,
1593 status: DiffStatus,
1594 first_bad_column: usize,
1595}
1596
1597impl JacobianSolution {
1598 /// The Jacobian, **only if** every column of this lane succeeded.
1599 ///
1600 /// Returns `None` for every non-[`DiffStatus::Ok`] status, so a caller
1601 /// cannot accidentally factorise a partially-`NaN` matrix believing it to
1602 /// be a Jacobian. The diagnostic matrix is behind the deliberately-named
1603 /// [`Self::raw_matrix`].
1604 #[must_use]
1605 pub fn matrix(&self) -> Option<&SquareMatrix> {
1606 if self.status.is_ok() {
1607 Some(&self.matrix)
1608 } else {
1609 None
1610 }
1611 }
1612
1613 /// Consume the solution, yielding the Jacobian only if it succeeded.
1614 #[must_use]
1615 pub fn into_matrix(self) -> Option<SquareMatrix> {
1616 if self.status.is_ok() {
1617 Some(self.matrix)
1618 } else {
1619 None
1620 }
1621 }
1622
1623 /// The matrix as assembled, whatever the status — a diagnostic, **not** an
1624 /// answer. Failed columns are filled with `NaN`.
1625 #[must_use]
1626 pub fn raw_matrix(&self) -> &SquareMatrix {
1627 &self.matrix
1628 }
1629
1630 /// Why this lane succeeded or failed.
1631 #[must_use]
1632 pub fn status(&self) -> DiffStatus {
1633 self.status
1634 }
1635
1636 /// Whether this lane produced a usable Jacobian.
1637 #[must_use]
1638 pub fn is_ok(&self) -> bool {
1639 self.status.is_ok()
1640 }
1641
1642 /// The index of the first column that failed, or `usize::MAX` if none did.
1643 ///
1644 /// "Column `j`" means the derivative with respect to `x[j]`, so this points
1645 /// straight at the offending *variable* rather than at the offending
1646 /// equation — which is the useful direction, because a failed column
1647 /// usually means the step took `x[j]` somewhere the function is not
1648 /// defined.
1649 #[must_use]
1650 pub fn first_bad_column(&self) -> usize {
1651 self.first_bad_column
1652 }
1653}
1654
1655/// The result of [`jacobian_batch`] — one [`JacobianSolution`] per lane.
1656#[derive(Debug, Clone)]
1657pub struct JacobianBatch {
1658 solutions: Vec<JacobianSolution>,
1659}
1660
1661impl JacobianBatch {
1662 /// Every lane's solution, in the order the points were supplied.
1663 #[must_use]
1664 pub fn solutions(&self) -> &[JacobianSolution] {
1665 &self.solutions
1666 }
1667
1668 /// Consume the batch, yielding the per-lane solutions.
1669 #[must_use]
1670 pub fn into_solutions(self) -> Vec<JacobianSolution> {
1671 self.solutions
1672 }
1673
1674 /// Number of lanes.
1675 #[must_use]
1676 pub fn len(&self) -> usize {
1677 self.solutions.len()
1678 }
1679
1680 /// Whether the batch has no lanes.
1681 #[must_use]
1682 pub fn is_empty(&self) -> bool {
1683 self.solutions.is_empty()
1684 }
1685
1686 /// Lane `i`'s solution, or `None` if `i` is out of range.
1687 #[must_use]
1688 pub fn get(&self, i: usize) -> Option<&JacobianSolution> {
1689 self.solutions.get(i)
1690 }
1691
1692 /// Whether every lane produced a usable Jacobian.
1693 #[must_use]
1694 pub fn all_ok(&self) -> bool {
1695 self.solutions.iter().all(JacobianSolution::is_ok)
1696 }
1697
1698 /// How many lanes failed.
1699 #[must_use]
1700 pub fn failure_count(&self) -> usize {
1701 self.solutions.iter().filter(|s| !s.is_ok()).count()
1702 }
1703
1704 /// The first failing lane index and its status, if any.
1705 #[must_use]
1706 pub fn first_failure(&self) -> Option<(usize, DiffStatus)> {
1707 self.solutions
1708 .iter()
1709 .enumerate()
1710 .find(|(_, s)| !s.is_ok())
1711 .map(|(i, s)| (i, s.status()))
1712 }
1713
1714 /// Every lane's Jacobian, **all or nothing**.
1715 ///
1716 /// # Errors
1717 ///
1718 /// [`DiffBatchFailure`] naming the failure count and the first failing lane
1719 /// if any lane failed. A `Vec` containing a partially-`NaN` matrix is never
1720 /// returned.
1721 pub fn matrices(self) -> Result<Vec<SquareMatrix>, DiffBatchFailure> {
1722 self.check_all_ok()?;
1723 Ok(self.solutions.into_iter().map(|s| s.matrix).collect())
1724 }
1725
1726 /// `Err` describing the first failure, if any lane failed.
1727 ///
1728 /// # Errors
1729 ///
1730 /// [`DiffBatchFailure`] as for [`Self::matrices`].
1731 pub fn check_all_ok(&self) -> Result<(), DiffBatchFailure> {
1732 if let Some((i, status)) = self.first_failure() {
1733 return Err(DiffBatchFailure {
1734 total: self.solutions.len(),
1735 failure_count: self.failure_count(),
1736 first_index: i,
1737 first_status: status,
1738 });
1739 }
1740 Ok(())
1741 }
1742}
1743
1744/// Assemble the Jacobian `J[i][j] = d f_i / d x_j` of one `n`-dimensional
1745/// vector function at one point, by finite differences.
1746///
1747/// The direct feeder for multi-dimensional Newton and — through
1748/// [`NumericalJacobian`] — for [`crate::ode::Rosenbrock23`].
1749///
1750/// # Square only
1751///
1752/// `f` must return exactly `x.len()` components. A rectangular Jacobian is
1753/// rejected with [`DiffStatus::DimensionMismatch`] rather than silently padded.
1754/// This restriction is deliberate and matches the prior art: the consumer
1755/// (`n` ODE equations in `n` states) is square, the crate's [`SquareMatrix`] is
1756/// square, and `outram-park-fork-dwsim-libs`' `finite_difference_jacobian`
1757/// rejects the non-square case too.
1758///
1759/// # Arguments
1760///
1761/// - `x` — the point, one component per variable, in the caller's own units.
1762/// - `settings` — scheme and step-size policy; see [`DiffSettings`]. The step
1763/// is computed per-column from that column's own `x[j]`, so variables of
1764/// wildly different magnitude each get an appropriate step.
1765/// - `backend` — requested backend for spreading the **columns** of this one
1766/// Jacobian. What actually runs is [`jacobian_column_backend_for`] applied to
1767/// it; a small `n` runs serially. **None of the degradations changes the
1768/// answer.**
1769/// - `f` — `f(0, x, out)` must fill `out` with the `n` function components at
1770/// `x`. The lane index is always `0` here; it is in the signature so the same
1771/// closure works with [`jacobian_batch`]. It **must be a pure deterministic
1772/// function of its arguments**.
1773///
1774/// # Returns
1775///
1776/// A [`JacobianSolution`] whose [`matrix`](JacobianSolution::matrix) is `Some`
1777/// only if every column succeeded.
1778///
1779/// # Cost
1780///
1781/// [`DiffScheme::evaluations_per_jacobian`] calls to `f`: `n + 1` for
1782/// [`DiffScheme::Forward`]/[`DiffScheme::Backward`], `2n` for
1783/// [`DiffScheme::Central`], `4n` for [`DiffScheme::Central4th`].
1784///
1785/// # Example
1786///
1787/// ```rust
1788/// use outram_foam_basic_lib::compute::ComputeBackend;
1789/// use outram_foam_basic_lib::math::differentiate::{jacobian, DiffSettings};
1790///
1791/// // f(x, y) = [x^2 * y, sin(x) + y^3]
1792/// // J = [[2xy, x^2], [cos(x), 3y^2]]
1793/// let point = [1.5_f64, 2.0];
1794/// let s = jacobian(
1795/// &point,
1796/// DiffSettings::central(),
1797/// ComputeBackend::Serial,
1798/// |_, v: &[f64], out: &mut Vec<f64>| {
1799/// out.push(v[0] * v[0] * v[1]);
1800/// out.push(v[0].sin() + v[1] * v[1] * v[1]);
1801/// },
1802/// );
1803///
1804/// let j = s.matrix().expect("smooth everywhere");
1805/// let (x, y) = (point[0], point[1]);
1806/// for (got, want) in [
1807/// (j.get(0, 0), 2.0 * x * y),
1808/// (j.get(0, 1), x * x),
1809/// (j.get(1, 0), x.cos()),
1810/// (j.get(1, 1), 3.0 * y * y),
1811/// ] {
1812/// assert!((got - want).abs() < 1e-8 * want.abs().max(1.0), "{got} vs {want}");
1813/// }
1814/// ```
1815#[must_use]
1816pub fn jacobian<F>(
1817 x: &[f64],
1818 settings: DiffSettings,
1819 backend: ComputeBackend,
1820 f: F,
1821) -> JacobianSolution
1822where
1823 F: Fn(usize, &[f64], &mut Vec<f64>) + Sync,
1824{
1825 jacobian_columns_min(0, x, settings, backend, JACOBIAN_COLUMN_MIN_DIMENSION, &f)
1826}
1827
1828/// Assemble `N` independent Jacobians, one per lane, on the chosen backend.
1829///
1830/// This is the batched form: the parallel axis is the **lane**, not the column,
1831/// so it is the right entry point when there are many small Jacobians (a
1832/// per-cell chemistry Jacobian over a mesh, an ensemble of ODE systems). Use
1833/// [`jacobian`] when there is one large Jacobian instead.
1834///
1835/// # The flat point layout
1836///
1837/// `points` is a **flat, row-major** buffer of `lanes * n` values: lane `i`'s
1838/// point is `points[i * n .. (i + 1) * n]`. A `&[Vec<f64>]` would be the
1839/// obvious alternative and is rejected on purpose — it costs one allocation and
1840/// one pointer chase per lane, and it is not the layout a GPU buffer would ever
1841/// take. `points.len()` must be an exact multiple of `n`.
1842///
1843/// # Arguments
1844///
1845/// - `points` — flat `lanes * n` buffer as above, in the caller's own units.
1846/// - `n` — the dimension of each point, dimensionless. Must be non-zero.
1847/// - `settings` — scheme and step-size policy, shared by every lane.
1848/// - `backend` — requested backend; see [`jacobian_batch_backend_for`]. Each
1849/// lane's columns are computed serially, since the lane axis is already the
1850/// parallel one.
1851/// - `f` — `f(i, x, out)` must fill `out` with lane `i`'s `n` function
1852/// components at `x`. It **must be a pure deterministic function of its
1853/// arguments**.
1854///
1855/// # Returns
1856///
1857/// A [`JacobianBatch`] with one solution per lane, in lane order. An empty
1858/// `points` slice, or `n == 0`, returns an empty batch and calls `f` zero
1859/// times. A `points.len()` that is not a multiple of `n` returns an empty batch
1860/// as well — it is a caller bug, not a numerical failure, and there is no
1861/// sensible lane count to report per-lane statuses against.
1862///
1863/// # Cost
1864///
1865/// `lanes * `[`DiffScheme::evaluations_per_jacobian`]`(n)` calls to `f`.
1866///
1867/// # Example
1868///
1869/// ```rust
1870/// use outram_foam_basic_lib::compute::ComputeBackend;
1871/// use outram_foam_basic_lib::math::differentiate::{jacobian_batch, DiffSettings};
1872///
1873/// // 3 lanes of the 2-D rotation-like system f = [-k_i * y, k_i * x],
1874/// // whose Jacobian is [[0, -k_i], [k_i, 0]].
1875/// let k = [1.0_f64, 2.5, 7.0];
1876/// let points: Vec<f64> = vec![0.3, -0.7, 0.3, -0.7, 0.3, -0.7];
1877///
1878/// let batch = jacobian_batch(
1879/// &points,
1880/// 2,
1881/// DiffSettings::central(),
1882/// ComputeBackend::CpuMulti,
1883/// |i, v: &[f64], out: &mut Vec<f64>| {
1884/// out.push(-k[i] * v[1]);
1885/// out.push(k[i] * v[0]);
1886/// },
1887/// );
1888///
1889/// let mats = batch.matrices().expect("linear system, exact everywhere");
1890/// for (i, m) in mats.iter().enumerate() {
1891/// assert!((m.get(0, 1) + k[i]).abs() < 1e-9);
1892/// assert!((m.get(1, 0) - k[i]).abs() < 1e-9);
1893/// }
1894/// ```
1895#[must_use]
1896pub fn jacobian_batch<F>(
1897 points: &[f64],
1898 n: usize,
1899 settings: DiffSettings,
1900 backend: ComputeBackend,
1901 f: F,
1902) -> JacobianBatch
1903where
1904 F: Fn(usize, &[f64], &mut Vec<f64>) + Sync,
1905{
1906 jacobian_batch_min(points, n, settings, backend, JACOBIAN_BATCH_MIN_PROBLEMS, f)
1907}
1908
1909/// [`jacobian_batch`] with the size floor supplied by the caller.
1910///
1911/// Exists so the crossover benchmark can measure the multi-CPU path *below*
1912/// [`JACOBIAN_BATCH_MIN_PROBLEMS`], and so the cross-backend bitwise tests are
1913/// not vacuous on small batches. Not public.
1914pub(crate) fn jacobian_batch_min<F>(
1915 points: &[f64],
1916 n: usize,
1917 settings: DiffSettings,
1918 backend: ComputeBackend,
1919 min_problems: usize,
1920 f: F,
1921) -> JacobianBatch
1922where
1923 F: Fn(usize, &[f64], &mut Vec<f64>) + Sync,
1924{
1925 if n == 0 || points.is_empty() || !points.len().is_multiple_of(n) {
1926 return JacobianBatch {
1927 solutions: Vec::new(),
1928 };
1929 }
1930 let lanes = points.len() / n;
1931 // Per-lane columns always run serially: the lane axis is the parallel one,
1932 // and nesting a rayon map inside a rayon map only fights the same pool.
1933 let solutions: Vec<JacobianSolution> = match effective_backend(backend, lanes, min_problems) {
1934 #[cfg(feature = "parallel")]
1935 ComputeBackend::CpuMulti => points
1936 .par_chunks(n)
1937 .enumerate()
1938 .map(|(i, x)| {
1939 jacobian_columns_min(i, x, settings, ComputeBackend::Serial, usize::MAX, &f)
1940 })
1941 .collect(),
1942 _ => points
1943 .chunks(n)
1944 .enumerate()
1945 .map(|(i, x)| {
1946 jacobian_columns_min(i, x, settings, ComputeBackend::Serial, usize::MAX, &f)
1947 })
1948 .collect(),
1949 };
1950 JacobianBatch { solutions }
1951}
1952
1953/// Assemble one lane's Jacobian, spreading the columns across `backend` when
1954/// the dimension justifies it.
1955///
1956/// Delegates to [`jacobian_columns_serial`] for everything except the `rayon`
1957/// arm, and both arms call the same [`jacobian_column`] kernel — which is why
1958/// every public Jacobian entry point agrees bit for bit.
1959fn jacobian_columns_min<F>(
1960 lane: usize,
1961 x: &[f64],
1962 settings: DiffSettings,
1963 backend: ComputeBackend,
1964 min_dimension: usize,
1965 f: &F,
1966) -> JacobianSolution
1967where
1968 F: Fn(usize, &[f64], &mut Vec<f64>) + Sync,
1969{
1970 match effective_backend(backend, x.len(), min_dimension) {
1971 #[cfg(feature = "parallel")]
1972 ComputeBackend::CpuMulti => {
1973 let n = x.len();
1974 let base = match jacobian_base(lane, x, settings, f) {
1975 Ok(b) => b,
1976 Err(status) => return failed_jacobian(n, status, 0),
1977 };
1978 if !x.iter().all(|v| v.is_finite()) {
1979 return failed_jacobian(n, DiffStatus::InvalidPoint, 0);
1980 }
1981 let base_slice = base.as_deref();
1982 let columns: Vec<Result<Vec<f64>, DiffStatus>> = (0..n)
1983 .into_par_iter()
1984 .map(|j| jacobian_column(lane, x, j, settings, base_slice, f))
1985 .collect();
1986 assemble_columns(n, columns)
1987 }
1988 _ => jacobian_columns_serial(lane, x, settings, f),
1989 }
1990}
1991
1992/// One lane's Jacobian, columns computed on the calling thread.
1993///
1994/// Carries **no** `Sync` bound, which is what lets [`ode_system_jacobian`]
1995/// difference an `OdeSystem` that is not itself `Sync`.
1996fn jacobian_columns_serial<F>(
1997 lane: usize,
1998 x: &[f64],
1999 settings: DiffSettings,
2000 f: &F,
2001) -> JacobianSolution
2002where
2003 F: Fn(usize, &[f64], &mut Vec<f64>),
2004{
2005 let n = x.len();
2006 if n == 0 {
2007 return JacobianSolution {
2008 matrix: SquareMatrix::new(0),
2009 status: DiffStatus::Ok,
2010 first_bad_column: usize::MAX,
2011 };
2012 }
2013 if !x.iter().all(|v| v.is_finite()) {
2014 return failed_jacobian(n, DiffStatus::InvalidPoint, 0);
2015 }
2016 let base = match jacobian_base(lane, x, settings, f) {
2017 Ok(b) => b,
2018 Err(status) => return failed_jacobian(n, status, 0),
2019 };
2020 let base_slice = base.as_deref();
2021 let columns: Vec<Result<Vec<f64>, DiffStatus>> = (0..n)
2022 .map(|j| jacobian_column(lane, x, j, settings, base_slice, f))
2023 .collect();
2024 assemble_columns(n, columns)
2025}
2026
2027/// The unperturbed evaluation `f(x)`, shared by every column of a one-sided
2028/// scheme — which is what makes [`DiffScheme::Forward`] cost `n + 1`
2029/// evaluations rather than `2n`. `None` for the symmetric schemes, whose
2030/// stencils never touch `x` itself.
2031fn jacobian_base<F>(
2032 lane: usize,
2033 x: &[f64],
2034 settings: DiffSettings,
2035 f: &F,
2036) -> Result<Option<Vec<f64>>, DiffStatus>
2037where
2038 F: Fn(usize, &[f64], &mut Vec<f64>),
2039{
2040 match settings.scheme {
2041 DiffScheme::Forward | DiffScheme::Backward => {
2042 let n = x.len();
2043 let mut out = Vec::with_capacity(n);
2044 f(lane, x, &mut out);
2045 if out.len() != n {
2046 return Err(DiffStatus::DimensionMismatch);
2047 }
2048 if !out.iter().all(|v| v.is_finite()) {
2049 return Err(DiffStatus::NotFinite);
2050 }
2051 Ok(Some(out))
2052 }
2053 DiffScheme::Central | DiffScheme::Central4th => Ok(None),
2054 }
2055}
2056
2057/// Pack per-column results into a [`SquareMatrix`], filling failed columns with
2058/// `NaN` and recording the first failure.
2059fn assemble_columns(n: usize, columns: Vec<Result<Vec<f64>, DiffStatus>>) -> JacobianSolution {
2060 let mut matrix = SquareMatrix::new(n);
2061 let mut status = DiffStatus::Ok;
2062 let mut first_bad_column = usize::MAX;
2063 for (j, column) in columns.into_iter().enumerate() {
2064 match column {
2065 Ok(values) => {
2066 for (i, v) in values.into_iter().enumerate() {
2067 matrix.set(i, j, v);
2068 }
2069 }
2070 Err(bad) => {
2071 for i in 0..n {
2072 matrix.set(i, j, f64::NAN);
2073 }
2074 if status.is_ok() {
2075 status = bad;
2076 first_bad_column = j;
2077 }
2078 }
2079 }
2080 }
2081 JacobianSolution {
2082 matrix,
2083 status,
2084 first_bad_column,
2085 }
2086}
2087
2088/// An all-`NaN` Jacobian carrying the reason it could not be formed.
2089fn failed_jacobian(n: usize, status: DiffStatus, first_bad_column: usize) -> JacobianSolution {
2090 let mut matrix = SquareMatrix::new(n);
2091 for i in 0..n {
2092 for j in 0..n {
2093 matrix.set(i, j, f64::NAN);
2094 }
2095 }
2096 JacobianSolution {
2097 matrix,
2098 status,
2099 first_bad_column,
2100 }
2101}
2102
2103/// Column `j` of the Jacobian: `d f / d x_j`, all `n` components at once.
2104///
2105/// `base` carries the shared unperturbed evaluation for the one-sided schemes
2106/// and is `None` for the symmetric ones.
2107#[inline]
2108fn jacobian_column<F>(
2109 lane: usize,
2110 x: &[f64],
2111 j: usize,
2112 settings: DiffSettings,
2113 base: Option<&[f64]>,
2114 f: &F,
2115) -> Result<Vec<f64>, DiffStatus>
2116where
2117 F: Fn(usize, &[f64], &mut Vec<f64>),
2118{
2119 let n = x.len();
2120 let h = settings.step_for(x[j]);
2121 if !h.is_finite() || h <= 0.0 {
2122 return Err(DiffStatus::DegenerateStep);
2123 }
2124
2125 match settings.scheme {
2126 DiffScheme::Forward => one_sided_column(lane, x, j, h, base, f),
2127 DiffScheme::Backward => one_sided_column(lane, x, j, -h, base, f),
2128 DiffScheme::Central => central_column(lane, x, j, h, f),
2129 DiffScheme::Central4th => {
2130 let coarse = central_column(lane, x, j, h, f)?;
2131 let fine = central_column(lane, x, j, 0.5 * h, f)?;
2132 let mut out = Vec::with_capacity(n);
2133 for (c, fi) in coarse.into_iter().zip(fine) {
2134 let v = (4.0 * fi - c) / 3.0;
2135 if !v.is_finite() {
2136 return Err(DiffStatus::NotFinite);
2137 }
2138 out.push(v);
2139 }
2140 Ok(out)
2141 }
2142 }
2143}
2144
2145/// Forward (`h > 0`) or backward (`h < 0`) column, reusing the shared base.
2146#[inline]
2147fn one_sided_column<F>(
2148 lane: usize,
2149 x: &[f64],
2150 j: usize,
2151 h: f64,
2152 base: Option<&[f64]>,
2153 f: &F,
2154) -> Result<Vec<f64>, DiffStatus>
2155where
2156 F: Fn(usize, &[f64], &mut Vec<f64>),
2157{
2158 let n = x.len();
2159 let base = base.ok_or(DiffStatus::DegenerateStep)?;
2160 let mut xp = x.to_vec();
2161 xp[j] = x[j] + h;
2162 let dh = xp[j] - x[j];
2163 if dh == 0.0 || !dh.is_finite() {
2164 return Err(DiffStatus::DegenerateStep);
2165 }
2166 let mut fp = Vec::with_capacity(n);
2167 f(lane, &xp, &mut fp);
2168 if fp.len() != n {
2169 return Err(DiffStatus::DimensionMismatch);
2170 }
2171 let mut out = Vec::with_capacity(n);
2172 for (a, b) in fp.into_iter().zip(base) {
2173 let v = (a - b) / dh;
2174 if !v.is_finite() {
2175 return Err(DiffStatus::NotFinite);
2176 }
2177 out.push(v);
2178 }
2179 Ok(out)
2180}
2181
2182/// Central column with half-width `h`.
2183#[inline]
2184fn central_column<F>(
2185 lane: usize,
2186 x: &[f64],
2187 j: usize,
2188 h: f64,
2189 f: &F,
2190) -> Result<Vec<f64>, DiffStatus>
2191where
2192 F: Fn(usize, &[f64], &mut Vec<f64>),
2193{
2194 let n = x.len();
2195 // One probe buffer, reused for both stencil points -- the two evaluations
2196 // differ in a single component, so a second copy of `x` buys nothing.
2197 let mut probe = x.to_vec();
2198 probe[j] = x[j] + h;
2199 let plus = probe[j];
2200 probe[j] = x[j] - h;
2201 let minus = probe[j];
2202 let dh = plus - minus;
2203 if dh == 0.0 || !dh.is_finite() {
2204 return Err(DiffStatus::DegenerateStep);
2205 }
2206 let mut fm = Vec::with_capacity(n);
2207 f(lane, &probe, &mut fm);
2208 probe[j] = plus;
2209 let mut fp = Vec::with_capacity(n);
2210 f(lane, &probe, &mut fp);
2211 if fp.len() != n || fm.len() != n {
2212 return Err(DiffStatus::DimensionMismatch);
2213 }
2214 let mut out = Vec::with_capacity(n);
2215 for (a, b) in fp.into_iter().zip(fm) {
2216 let v = (a - b) / dh;
2217 if !v.is_finite() {
2218 return Err(DiffStatus::NotFinite);
2219 }
2220 out.push(v);
2221 }
2222 Ok(out)
2223}
2224
2225// ── The ODE consumer ─────────────────────────────────────────────────────────
2226
2227/// Fill an [`OdeSystem`]'s Jacobian slots by finite differences.
2228///
2229/// This is the free-function form of what [`NumericalJacobian`] does, for
2230/// callers who already have an `OdeSystem` and want the numbers rather than a
2231/// wrapper. It fills exactly the two buffers
2232/// [`OdeSystem::jacobian`] is contracted to fill:
2233///
2234/// - `dfdy[i][j] = d f_i / d y_j`, an `n x n` [`SquareMatrix`];
2235/// - `dfdx[i] = d f_i / d x`, the derivative with respect to the **independent
2236/// variable** (time, for a transient), length `n`.
2237///
2238/// Both are resized to `n = system.n_eqns()` if the caller's buffers are the
2239/// wrong size, so it is safe to pass freshly-defaulted ones.
2240///
2241/// # Failure is written into the buffers, not swallowed
2242///
2243/// On failure the offending entries are filled with `NaN` **and** the reason is
2244/// returned. Nothing is quietly zeroed. This matters: filling a failed entry
2245/// with `0.0` — which
2246/// `outram-park-fork-dwsim-libs`' `finite_difference_jacobian` does — turns an
2247/// undetectable-at-the-call-site failure into a plausible-looking Jacobian, and
2248/// a Rosenbrock step built on it produces a wrong trajectory rather than an
2249/// error. With `NaN` the failure propagates into the step, the normalised error
2250/// estimate becomes `NaN`, the step controller shrinks `dx` and
2251/// [`crate::ode::OdeError::StepSizeUnderflow`] is reported. Loud is better.
2252///
2253/// # Arguments
2254///
2255/// - `system` — the ODE system whose [`OdeSystem::derivatives`] is sampled.
2256/// - `x` — the independent variable, caller's units.
2257/// - `y` — the state, length `system.n_eqns()`.
2258/// - `settings` — scheme and step-size policy; see [`DiffSettings`].
2259/// - `dfdx`, `dfdy` — output buffers, filled in place.
2260///
2261/// # Returns
2262///
2263/// [`DiffStatus::Ok`] if every entry of both outputs was formed from finite
2264/// evaluations; the first failing status otherwise.
2265///
2266/// # Cost
2267///
2268/// `1 + `[`DiffScheme::evaluations_per_jacobian`]`(n)` calls to
2269/// [`OdeSystem::derivatives`] for the one-sided schemes — the base evaluation
2270/// is shared between `dfdy`'s columns and `dfdx` — and
2271/// [`DiffScheme::evaluations_per_jacobian`]`(n) + 2` or `+ 4` for the symmetric
2272/// ones.
2273///
2274/// # Example
2275///
2276/// ```rust
2277/// use outram_foam_basic_lib::math::differentiate::{
2278/// ode_system_jacobian, DiffSettings, DiffStatus,
2279/// };
2280/// use outram_foam_basic_lib::matrix::SquareMatrix;
2281/// use outram_foam_basic_lib::ode::OdeSystem;
2282///
2283/// // dy/dx = [-2*y0 + y1, x * y0] -- Jacobian [[-2, 1], [x, 0]], dfdx = [0, y0].
2284/// struct Linear;
2285/// impl OdeSystem for Linear {
2286/// fn n_eqns(&self) -> usize { 2 }
2287/// fn derivatives(&self, x: f64, y: &[f64], dydx: &mut Vec<f64>) {
2288/// dydx.clear();
2289/// dydx.push(-2.0 * y[0] + y[1]);
2290/// dydx.push(x * y[0]);
2291/// }
2292/// }
2293///
2294/// let mut dfdx = Vec::new();
2295/// let mut dfdy = SquareMatrix::new(2);
2296/// let status = ode_system_jacobian(
2297/// &Linear, 0.5, &[1.0, 2.0], DiffSettings::central(), &mut dfdx, &mut dfdy,
2298/// );
2299///
2300/// assert_eq!(status, DiffStatus::Ok);
2301/// assert!((dfdy.get(0, 0) + 2.0).abs() < 1e-8);
2302/// assert!((dfdy.get(0, 1) - 1.0).abs() < 1e-8);
2303/// assert!((dfdy.get(1, 0) - 0.5).abs() < 1e-8);
2304/// assert!(dfdy.get(1, 1).abs() < 1e-8);
2305/// assert!(dfdx[0].abs() < 1e-8);
2306/// assert!((dfdx[1] - 1.0).abs() < 1e-8); // d/dx (x * y0) = y0 = 1
2307/// ```
2308pub fn ode_system_jacobian<S>(
2309 system: &S,
2310 x: f64,
2311 y: &[f64],
2312 settings: DiffSettings,
2313 dfdx: &mut Vec<f64>,
2314 dfdy: &mut SquareMatrix,
2315) -> DiffStatus
2316where
2317 S: OdeSystem + ?Sized,
2318{
2319 let n = system.n_eqns();
2320 if dfdx.len() != n {
2321 dfdx.resize(n, 0.0);
2322 }
2323 if dfdy.n() != n {
2324 *dfdy = SquareMatrix::new(n);
2325 }
2326
2327 // d f_i / d y_j -- the state Jacobian. Columns run serially: an ODE
2328 // Jacobian is small (n is equation count, typically single or double
2329 // digits) and `ode::parallel::integrate_ensemble` already provides the
2330 // parallel axis that matters for ODE work, which is the ensemble lane.
2331 let solution = jacobian_columns_serial(
2332 0,
2333 y,
2334 settings,
2335 &|_: usize, state: &[f64], out: &mut Vec<f64>| {
2336 out.clear();
2337 system.derivatives(x, state, out);
2338 },
2339 );
2340 for i in 0..n {
2341 for j in 0..n {
2342 dfdy.set(i, j, solution.raw_matrix().get(i, j));
2343 }
2344 }
2345 let mut status = solution.status();
2346
2347 // d f_i / d x -- the explicit dependence on the independent variable.
2348 // Same stencil, same step rule, one scalar direction.
2349 let x_status = ode_dfdx(system, x, y, settings, n, dfdx);
2350 if status.is_ok() {
2351 status = x_status;
2352 }
2353 status
2354}
2355
2356/// Fill `dfdx` — the derivative of each equation with respect to the
2357/// independent variable, holding the state fixed.
2358fn ode_dfdx<S>(
2359 system: &S,
2360 x: f64,
2361 y: &[f64],
2362 settings: DiffSettings,
2363 n: usize,
2364 dfdx: &mut [f64],
2365) -> DiffStatus
2366where
2367 S: OdeSystem + ?Sized,
2368{
2369 // Treat `x` as a one-component point and reuse the column kernel, so the
2370 // stencil, the step rule and the realised-step correction are literally the
2371 // same code as for `dfdy`.
2372 let point = [x];
2373 let solution =
2374 jacobian_column_scalar_direction(&point, settings, n, &|t: f64, out: &mut Vec<f64>| {
2375 out.clear();
2376 system.derivatives(t, y, out);
2377 });
2378 match solution {
2379 Ok(values) => {
2380 dfdx[..n].copy_from_slice(&values[..n]);
2381 DiffStatus::Ok
2382 }
2383 Err(status) => {
2384 for v in dfdx.iter_mut().take(n) {
2385 *v = f64::NAN;
2386 }
2387 status
2388 }
2389 }
2390}
2391
2392/// The `dfdx` stencil: one scalar direction, `n` outputs.
2393///
2394/// Structurally identical to [`jacobian_column`] with `j = 0`; kept separate
2395/// only because its function takes a bare `f64` rather than a slice, which is
2396/// what [`OdeSystem::derivatives`] wants for its independent variable.
2397fn jacobian_column_scalar_direction<G>(
2398 point: &[f64; 1],
2399 settings: DiffSettings,
2400 n: usize,
2401 g: &G,
2402) -> Result<Vec<f64>, DiffStatus>
2403where
2404 G: Fn(f64, &mut Vec<f64>),
2405{
2406 let x = point[0];
2407 if !x.is_finite() {
2408 return Err(DiffStatus::InvalidPoint);
2409 }
2410 let h = settings.step_for(x);
2411 if !h.is_finite() || h <= 0.0 {
2412 return Err(DiffStatus::DegenerateStep);
2413 }
2414
2415 let sample = |t: f64| -> Result<Vec<f64>, DiffStatus> {
2416 let mut out = Vec::with_capacity(n);
2417 g(t, &mut out);
2418 if out.len() != n {
2419 return Err(DiffStatus::DimensionMismatch);
2420 }
2421 if out.iter().any(|v| !v.is_finite()) {
2422 return Err(DiffStatus::NotFinite);
2423 }
2424 Ok(out)
2425 };
2426 let quotient = |a: Vec<f64>, b: Vec<f64>, dh: f64| -> Result<Vec<f64>, DiffStatus> {
2427 let mut out = Vec::with_capacity(n);
2428 for (p, q) in a.into_iter().zip(b) {
2429 let v = (p - q) / dh;
2430 if !v.is_finite() {
2431 return Err(DiffStatus::NotFinite);
2432 }
2433 out.push(v);
2434 }
2435 Ok(out)
2436 };
2437 let one_sided = |step: f64| -> Result<Vec<f64>, DiffStatus> {
2438 let xp = x + step;
2439 let dh = xp - x;
2440 if dh == 0.0 || !dh.is_finite() {
2441 return Err(DiffStatus::DegenerateStep);
2442 }
2443 quotient(sample(xp)?, sample(x)?, dh)
2444 };
2445 let central = |half: f64| -> Result<Vec<f64>, DiffStatus> {
2446 let (xp, xm) = (x + half, x - half);
2447 let dh = xp - xm;
2448 if dh == 0.0 || !dh.is_finite() {
2449 return Err(DiffStatus::DegenerateStep);
2450 }
2451 quotient(sample(xp)?, sample(xm)?, dh)
2452 };
2453
2454 match settings.scheme {
2455 DiffScheme::Forward => one_sided(h),
2456 DiffScheme::Backward => one_sided(-h),
2457 DiffScheme::Central => central(h),
2458 DiffScheme::Central4th => {
2459 let coarse = central(h)?;
2460 let fine = central(0.5 * h)?;
2461 let mut out = Vec::with_capacity(n);
2462 for (c, fi) in coarse.into_iter().zip(fine) {
2463 let v = (4.0 * fi - c) / 3.0;
2464 if !v.is_finite() {
2465 return Err(DiffStatus::NotFinite);
2466 }
2467 out.push(v);
2468 }
2469 Ok(out)
2470 }
2471 }
2472}
2473
2474/// Wrap any [`OdeSystem`] so that [`crate::ode::Rosenbrock23`] can integrate it
2475/// **without a hand-coded Jacobian**.
2476///
2477/// # The problem this solves
2478///
2479/// [`OdeSystem::jacobian`] has a default body that is `unimplemented!()`, so a
2480/// system that does not override it panics the moment a stiff solver asks for a
2481/// Jacobian — inside `Rosenbrock23::inner_step`, and, if the integration is
2482/// running in an ensemble, out through the `rayon` scope. Every system that
2483/// only knows its own `derivatives` is locked out of the crate's only stiff
2484/// solver.
2485///
2486/// Wrapping it in `NumericalJacobian` supplies the missing method by finite
2487/// differences and changes nothing else: `n_eqns` and `derivatives` are
2488/// forwarded verbatim.
2489///
2490/// # Owning, not borrowing
2491///
2492/// The wrapper **owns** the system by value, so it needs no lifetime parameter
2493/// and no `Box` — both forbidden by the workspace design rules. Construct it
2494/// with [`Self::new`], get the system back with [`Self::into_inner`].
2495///
2496/// # An analytic Jacobian is still better
2497///
2498/// Finite differences cost `n + 1` to `4n` extra `derivatives` calls per
2499/// Rosenbrock stage and are accurate to roughly `sqrt(eps)` to `eps^(4/5)`
2500/// rather than to machine precision — see the module-level "Achievable
2501/// accuracy" table. If the analytic Jacobian is available, write it. This
2502/// wrapper is for the systems where it is not, and as a **verification oracle**
2503/// for the ones where it is: differencing a system that also implements
2504/// `jacobian` analytically and comparing is the cheapest real check that the
2505/// hand-derived version has no sign or transposition error.
2506///
2507/// # Units
2508///
2509/// Inherited from the wrapped system; nothing here is dimensioned.
2510///
2511/// # Example
2512///
2513/// ```rust
2514/// use outram_foam_basic_lib::math::differentiate::{DiffSettings, NumericalJacobian};
2515/// use outram_foam_basic_lib::ode::{OdeSystem, Rosenbrock23};
2516///
2517/// // A stiff scalar system with NO hand-coded Jacobian: dy/dx = -1000 y.
2518/// struct StiffDecay;
2519/// impl OdeSystem for StiffDecay {
2520/// fn n_eqns(&self) -> usize { 1 }
2521/// fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
2522/// dydx.clear();
2523/// dydx.push(-1000.0 * y[0]);
2524/// }
2525/// // no `jacobian` override -- the default would panic
2526/// }
2527///
2528/// let system = NumericalJacobian::new(StiffDecay, DiffSettings::central());
2529/// let mut solver = Rosenbrock23::new(1, 1e-10, 1e-10);
2530/// let mut y = vec![1.0_f64];
2531/// let mut dx = 1e-5;
2532/// solver.integrate(&system, 0.0, 0.01, &mut y, &mut dx).expect("integrates");
2533///
2534/// // exp(-1000 * 0.01) = exp(-10) = 4.5399929762484854e-5
2535/// let exact = (-10.0_f64).exp();
2536/// assert!((y[0] - exact).abs() < 1e-8, "got {}, want {exact}", y[0]);
2537/// assert_eq!(system.non_finite_jacobians(), 0);
2538/// ```
2539#[derive(Debug)]
2540pub struct NumericalJacobian<S> {
2541 system: S,
2542 settings: DiffSettings,
2543 non_finite: AtomicUsize,
2544}
2545
2546impl<S> NumericalJacobian<S> {
2547 /// Wrap `system`, differencing its `derivatives` with `settings`.
2548 ///
2549 /// # Arguments
2550 ///
2551 /// - `system` — owned by value.
2552 /// - `settings` — scheme and step-size policy. [`DiffSettings::central`] is
2553 /// the usual choice for a Rosenbrock Jacobian: `2n` evaluations for
2554 /// `O(h^2)` truncation, where [`DiffScheme::Central4th`] doubles the cost
2555 /// again for accuracy the step controller cannot exploit.
2556 pub fn new(system: S, settings: DiffSettings) -> Self {
2557 Self {
2558 system,
2559 settings,
2560 non_finite: AtomicUsize::new(0),
2561 }
2562 }
2563
2564 /// Borrow the wrapped system.
2565 pub fn inner(&self) -> &S {
2566 &self.system
2567 }
2568
2569 /// Unwrap, returning the system.
2570 pub fn into_inner(self) -> S {
2571 self.system
2572 }
2573
2574 /// The step-size policy in force.
2575 #[must_use]
2576 pub fn settings(&self) -> DiffSettings {
2577 self.settings
2578 }
2579
2580 /// How many [`OdeSystem::jacobian`] calls have failed since construction.
2581 ///
2582 /// The trait method returns `()`, so it has nowhere to report a failure;
2583 /// this counter is that report. A non-zero value means at least one
2584 /// Jacobian was handed to the solver with `NaN` entries, which the solver
2585 /// will have turned into [`crate::ode::OdeError::StepSizeUnderflow`] rather
2586 /// than a wrong answer — but it is worth knowing *why* an integration
2587 /// failed, and "the Jacobian could not be differenced" is a different bug
2588 /// from "the system is too stiff".
2589 ///
2590 /// # Units
2591 ///
2592 /// A count, dimensionless.
2593 #[must_use]
2594 pub fn non_finite_jacobians(&self) -> usize {
2595 self.non_finite.load(Ordering::Relaxed)
2596 }
2597}
2598
2599impl<S> OdeSystem for NumericalJacobian<S>
2600where
2601 S: OdeSystem,
2602{
2603 fn n_eqns(&self) -> usize {
2604 self.system.n_eqns()
2605 }
2606
2607 fn derivatives(&self, x: f64, y: &[f64], dydx: &mut Vec<f64>) {
2608 self.system.derivatives(x, y, dydx);
2609 }
2610
2611 fn jacobian(&self, x: f64, y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
2612 let status = ode_system_jacobian(&self.system, x, y, self.settings, dfdx, dfdy);
2613 if !status.is_ok() {
2614 self.non_finite.fetch_add(1, Ordering::Relaxed);
2615 }
2616 }
2617}