outram_foam_basic_lib/ode/parallel.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//! Batched **numerical integration** on the hybrid execution backend — `N`
20//! independent initial-value problems, or `N` independent definite integrals,
21//! advanced at once, serially or across CPU cores.
22//!
23//! # The two things in here, and why they share a module
24//!
25//! | Operation | The batch is | Entry point |
26//! |---|---|---|
27//! | ODE ensemble, one stepper for every lane | `N` independent IVPs | [`integrate_ensemble`] |
28//! | ODE ensemble, stepper chosen per lane | `N` independent IVPs | [`integrate_ensemble_mixed`] |
29//! | Fixed-rule quadrature | `N` independent definite integrals | [`quadrature_batch`] |
30//! | Adaptive quadrature | `N` independent definite integrals | [`adaptive_quadrature_batch`] |
31//!
32//! Both halves are "numerical integration" in the sense bead `op-yvj.4.5` uses
33//! the phrase, and a definite integral genuinely *is* the initial-value problem
34//! `dy/dx = f(x)`, `y(a) = 0`, read at `x = b` — so quadrature sitting beside the
35//! ODE steppers is not a filing accident. They also share every piece of
36//! machinery that is not the arithmetic itself: the lane vocabulary, the
37//! per-lane status reporting, the backend-degradation policy, and the
38//! determinism argument below. Keeping them together means the crate has **one**
39//! batched-integration dialect rather than two.
40//!
41//! # Reuse, not reimplementation
42//!
43//! **No new integrator is written here.** The ODE half drives the crate's
44//! existing steppers — [`Euler`](crate::ode::Euler),
45//! [`Rkf45`](crate::ode::Rkf45), [`Rosenbrock23`](crate::ode::Rosenbrock23),
46//! selected through the existing [`OdeSolver`] enum — over the existing
47//! adaptive interval loop. This module adds the *ensemble*: the outer loop over
48//! lanes, the per-lane outcome reporting, and the backend dispatch.
49//!
50//! The quadrature half is new code, because the workspace had no general
51//! quadrature. The only prior art is
52//! `outram-park-fork-dwsim-libs`'s `clean_energies::pem_fuel_cell::simpson_integrate`,
53//! which is deliberately **not** reused: it is a verbatim port of OPEM's rule
54//! *including its documented flaw* (composite Simpson weights applied without
55//! checking that the sample count is odd), and it integrates a slice of
56//! pre-sampled values rather than a callable integrand. [`QuadratureRule`]
57//! keeps the one convention that does carry over — composite rules over equal
58//! subintervals — and makes the sample-count error structurally impossible by
59//! counting Simpson *panels* of two subintervals each rather than raw samples.
60//! `raffles`'s `distributions::special::integrate_open_unit` is the other
61//! in-workspace quadrature: a composite 8-point Gauss-Legendre over
62//! geometrically graded panels, hard-wired to the open unit interval and to
63//! quantile-function moments. It is not a general routine and is private to that
64//! crate's `special` module, so it is cited here as precedent for the
65//! Gauss-Legendre choice rather than reused.
66//!
67//! # Hybrid means dispatch, not two APIs
68//!
69//! Every entry point takes a [`ComputeBackend`] parameter, and there is no
70//! `*_parallel()` sibling anywhere. With the `parallel` feature off,
71//! [`ComputeBackend::CpuMulti`] resolves down to [`ComputeBackend::Serial`]
72//! through [`ComputeBackend::resolve`] and the answer is unchanged, bit for bit.
73//! There is no GPU kernel in this module yet, so a [`ComputeBackend::Gpu`]
74//! request degrades to the best available CPU path — see "GPU" below for why
75//! that is not merely laziness for the adaptive paths.
76//!
77//! # Determinism — bitwise identical, and the summation-order question
78//!
79//! **Every kernel in this module returns bit-for-bit identical output on
80//! [`ComputeBackend::Serial`] and [`ComputeBackend::CpuMulti`], at any thread
81//! count, on every run**, provided the caller's system or integrand is itself a
82//! deterministic pure function of its arguments.
83//!
84//! For the ODE ensemble this is the same argument as
85//! [`crate::math::parallel`]'s: lane `i`'s trajectory is a pure function of lane
86//! `i`'s system, initial condition and stepper. No lane reads another lane's
87//! state, so there is no cross-lane arithmetic whose association could change,
88//! and both backends call the very same per-lane kernel. Each lane also gets its
89//! **own** clone of the stepper prototype, so no scratch buffer is ever shared
90//! between lanes and the result cannot depend on which lanes a worker happened
91//! to run first.
92//!
93//! Quadrature needs one extra sentence, because a quadrature rule *is* a sum and
94//! floating-point addition is not associative. The reason the answer is still
95//! bit-identical is the shape of the batch: **one lane is one integral, and one
96//! integral is summed sequentially by a single thread.** The parallelism is over
97//! lanes, never within a lane, so no partial sum is ever re-associated. This is
98//! a deliberate design choice with a real cost — it means a single very
99//! expensive integral gets no speed-up from this module at all — and it is taken
100//! because a reduction split across threads would give a different answer at
101//! every thread count, which for a verification oracle is disqualifying.
102//! **Splitting one integral's panels across threads is not offered here**, and
103//! if it is ever added it must be a separate, separately-named entry point whose
104//! documentation says plainly that it is not bit-reproducible.
105//!
106//! Verified by the `bitwise_*` tests in `parallel/tests.rs`, which compare
107//! serial against `rayon` pools of 1, 2, 4 and 8 workers on batches built to
108//! have wildly uneven per-lane cost.
109//!
110//! For scale, a 4 096-lane deliberately-imbalanced ensemble (half the lanes
111//! decaying at `k` near 1, half at `k` near 60, giving 278 318 accepted steps in
112//! total — a mean of 67.9 per lane against a maximum of 126) under `Rkf45`, measured
113//! 2026-08-13 on 4 logical cores by the `#[ignore]`d
114//! `ensemble_thread_scaling_benchmark`, best of 7 samples, with a second
115//! independent run alongside:
116//!
117//! | Worker threads | Time | Speed-up | (repeat) | Bitwise vs serial |
118//! |---|---|---|---|---|
119//! | *serial reference* | 35808.33 us | 1.00x | 1.00x | — |
120//! | 1 | 35839.25 us | 1.00x | 1.00x | identical |
121//! | 2 | 18283.82 us | 1.96x | 1.92x | identical |
122//! | 4 | 9276.65 us | 3.86x | 3.09x | identical |
123//! | 8 | 9128.96 us | 3.92x | 3.05x | identical |
124//!
125//! The "identical" column is the determinism claim above measured rather than
126//! argued, and it is asserted by the benchmark itself, not merely printed. Going
127//! through `rayon` with a single worker costs essentially nothing here (1.00x),
128//! unlike the batched root finder where it costs about 6% — the per-lane work is
129//! large enough that the iterator machinery disappears into it. Eight workers on
130//! four cores buy nothing further, the expected signature of a compute-bound
131//! kernel that already saturates its cores. **This is one machine, one ensemble
132//! and two runs; it is not a scaling study**, and nothing here has been measured
133//! on Android hardware or on a many-core server.
134//!
135//! # Load imbalance — why there is no hand-rolled partition
136//!
137//! An adaptive stepper takes a different number of sub-steps in every lane, and
138//! the spread is not small. On the benchmark ensemble below it is a mean of 67.9
139//! accepted steps against a maximum of 126; on the stiff pair in
140//! `parallel/tests.rs` a single lane under [`Rkf45`](crate::ode::Rkf45) burns
141//! all 10 000 of its allotted steps and still spans only a third of the
142//! interval, beside decay lanes finishing in 81. Adaptive quadrature has the
143//! same property by construction — 469 to 1 225 integrand evaluations across the
144//! three verification integrands. A static equal split across `P` threads
145//! therefore ends up waiting on whichever chunk drew the hard lanes.
146//!
147//! Every parallel path here uses `rayon`'s adaptive splitting with **no**
148//! `min_len` floor, so an idle worker can steal down to a single lane. That is
149//! the deliberate answer to the imbalance, not an oversight. No granularity
150//! floor is imposed even on the fixed-rule quadrature path, where the crate's
151//! closed-form polynomial kernels do impose one
152//! ([`crate::math::parallel::POLY_BLOCK`]): there, every lane provably costs the
153//! same handful of flops, whereas here the per-lane cost is set by the caller
154//! through [`QuadratureRule`]'s panel count *and* by the cost of the caller's
155//! integrand, so any fixed floor would be wrong for most callers. Work-stealing
156//! handles both ends without a number that cannot be justified.
157//!
158//! Whatever the splitter does, it cannot change a value — every lane is computed
159//! independently of every other.
160//!
161//! # Stiffness — how a mixed ensemble is handled
162//!
163//! The realistic per-cell chemistry ensemble is *mixed*: most cells are benign
164//! and a few are stiff. Three things follow, and all three are deliberate.
165//!
166//! 1. **The stepper is not switched behind the caller's back.** An ensemble run
167//! with [`integrate_ensemble`] uses one stepper for every lane. A stiff lane
168//! handed an explicit stepper does not silently return a wrong answer: the
169//! adaptive controller shrinks the step until it either meets tolerance —
170//! correct but slow — or runs out, at which point the lane reports
171//! [`OdeLaneStatus::MaxStepsExceeded`] or
172//! [`OdeLaneStatus::StepSizeUnderflow`]. Stiffness therefore shows up as a
173//! *named per-lane failure*, not as silent garbage.
174//! 2. **Per-lane stepper selection exists** — [`integrate_ensemble_mixed`] takes
175//! a closure `Fn(usize) -> OdeSolver`, so a caller that knows which cells are
176//! stiff (or that has just been told by a failed
177//! [`integrate_ensemble`] pass) can give those lanes
178//! [`Rosenbrock23`](crate::ode::Rosenbrock23) and leave the rest on
179//! [`Rkf45`](crate::ode::Rkf45). This is the intended recovery path and it
180//! costs nothing when unused.
181//! 3. **`Rosenbrock23` needs a Jacobian, and this module does not supply one.**
182//! [`OdeSystem::jacobian`](crate::ode::OdeSystem::jacobian)'s default
183//! implementation panics, and a panic inside a `rayon` worker will propagate
184//! out of the batch. Check
185//! [`OdeSolver::requires_jacobian`](crate::ode::OdeSolver::requires_jacobian)
186//! before selecting it for a system you did not write. Batched *numerical*
187//! Jacobians are bead `op-yvj.4.6`, not this one.
188//!
189//! # Non-convergence is reported, never swallowed
190//!
191//! An ensemble of 10 000 cells in which 3 fail must say so, and must say which
192//! 3. Both halves report failure **per lane**, and make it hard to ignore by
193//! construction — the same shape [`crate::math::parallel`] uses:
194//!
195//! - [`OdeLaneSolution::state`] and [`QuadratureSolution::value`] return
196//! `Option`, and hand back `Some` **only** for a lane that succeeded.
197//! - The raw number is behind the deliberately-named
198//! [`OdeLaneSolution::last_state`] / [`QuadratureSolution::last_value`], so
199//! using a failed lane's partial answer is a visible decision in the calling
200//! code rather than an accident.
201//! - [`OdeEnsemble::states`] and [`QuadratureBatch::values`] are all-or-nothing:
202//! they return `Err` naming the failure count and the first failing lane,
203//! rather than a plausible-looking `Vec`.
204//!
205//! **A lane that ran out of steps is never presented as if it had reached
206//! `x_end`.** It reports [`OdeLaneStatus::MaxStepsExceeded`], its genuine
207//! partial state, and the `x` it actually reached — which is exactly the
208//! information needed to decide whether to sub-cycle it or re-run it stiff. A
209//! lane whose inputs were unusable reports [`OdeLaneStatus::InvalidLane`] and a
210//! `NaN` state, because there is no honest number to return.
211//!
212//! # GPU
213//!
214//! There is no `wgpu` kernel here yet; `Gpu` degrades. Two of these four
215//! kernels would map to one if written, and two would not, and the distinction
216//! is worth recording rather than rediscovering:
217//!
218//! - **Fixed-rule quadrature is GPU-shaped.** Every lane evaluates the same
219//! fixed number of nodes with no data-dependent branching, so a warp never
220//! diverges.
221//! - **Adaptive quadrature is not, and is CPU-only by design.** Its subdivision
222//! pattern is decided by the integrand, so neighbouring lanes take different
223//! paths through the code and different numbers of evaluations; on SIMT
224//! hardware that serialises the divergent branches and needs a per-lane
225//! work stack. It stays on the CPU deliberately.
226//! - **Adaptive ODE ensembles have the same problem, sharpened.** A batch run
227//! lockstep at the smallest step any lane needs is simple and correct but
228//! wastes the whole ensemble's budget on its worst member; per-lane step
229//! control is correct but divergent. Neither is attempted here.
230//!
231//! Note also that WGSL has no `f64`, and the accuracy consequences of an `f32`
232//! quadrature or an `f32` error controller are unmeasured in this workspace.
233//!
234//! # Units
235//!
236//! Everything here is dimensionless `f64`, for the same reason
237//! [`crate::math::parallel`] is: a general integrator has no single physical
238//! dimension. One lane's abscissa may be a time in seconds and another's a
239//! length in metres, and the state vector's components routinely carry different
240//! dimensions from each other.
241//!
242//! `uom` typing is **not stripped** to get here — it is applied at the boundary,
243//! by the caller, exactly as the hybrid-backend epic requires: convert into the
244//! batch, convert back out. The doctests on [`integrate_ensemble`] and
245//! [`quadrature_batch`] show that boundary explicitly, one recovering a
246//! `ThermodynamicTemperature` and the other an `Energy`.
247//!
248//! # Cargo features and portability
249//!
250//! The `rayon` paths sit behind the crate's `parallel` feature, which is **off
251//! by default**; with it off this module still compiles and every entry point
252//! still works. `rayon` is pure Rust with no system component, so everything
253//! here compiles and runs on `aarch64-linux-android` / Termux exactly as on
254//! desktop. Nothing in this module is target-gated.
255//!
256//! # Example — an ensemble of independent decays
257//!
258//! ```rust
259//! use outram_foam_basic_lib::compute::ComputeBackend;
260//! use outram_foam_basic_lib::ode::{OdeSolver, OdeSystem};
261//! use outram_foam_basic_lib::ode::parallel::{integrate_ensemble, OdeLane};
262//!
263//! /// `dy/dx = -k y` — one lane per decay constant.
264//! struct Decay { k: f64 }
265//! impl OdeSystem for Decay {
266//! fn n_eqns(&self) -> usize { 1 }
267//! fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
268//! dydx[0] = -self.k * y[0];
269//! }
270//! }
271//!
272//! let lanes: Vec<OdeLane<Decay>> = (1..=4)
273//! .map(|i| OdeLane::new(Decay { k: i as f64 }, vec![1.0], 0.0, 1.0, 0.1))
274//! .collect();
275//!
276//! let ensemble = integrate_ensemble(
277//! &lanes,
278//! &OdeSolver::rkf45(1, 1e-10, 1e-8),
279//! ComputeBackend::CpuMulti,
280//! );
281//!
282//! let states = ensemble.states().expect("all four lanes complete");
283//! for (i, s) in states.iter().enumerate() {
284//! let exact = (-(i as f64 + 1.0)).exp();
285//! assert!((s[0] - exact).abs() < 1e-8, "lane {i}: {} vs {exact}", s[0]);
286//! }
287//!
288//! // Asking for multi-CPU gives a bit-for-bit identical answer, whether or not
289//! // the `parallel` feature is compiled in.
290//! let serial = integrate_ensemble(
291//! &lanes,
292//! &OdeSolver::rkf45(1, 1e-10, 1e-8),
293//! ComputeBackend::Serial,
294//! );
295//! assert_eq!(states, serial.states().unwrap());
296//! ```
297
298use petir::mathf::RealMath;
299#[cfg(feature = "parallel")]
300use rayon::prelude::*;
301
302use super::{OdeError, OdeSolver, OdeSystem};
303use crate::compute::ComputeBackend;
304
305#[cfg(test)]
306mod tests;
307
308// ── Tuning constants ─────────────────────────────────────────────────────────
309
310/// Lane count below which a [`ComputeBackend::CpuMulti`] request runs
311/// [`integrate_ensemble`] on the calling thread instead.
312///
313/// # Measured crossover
314///
315/// *Methodology.* Measured 2026-08-13 on this workspace's development machine,
316/// `std::thread::available_parallelism()` = **4**, release build, `--features
317/// parallel`, `rayon`'s global pool, machine otherwise idle (see the
318/// *Contention* note below, which is not a footnote — it changes the answer).
319/// Ensembles of `n` independent one-equation decay problems `dy/dx = -k_i y`,
320/// `y(0) = 1`, integrated from `x = 0` to `x = 1` by
321/// [`Rkf45`](crate::ode::Rkf45) with `abs_tol = 1e-10`, `rel_tol = 1e-8`,
322/// initial step `0.1`. Half the lanes are given `k_i` in `[0.5, 1.5)` and half
323/// in `[50, 70)`, so the ensemble is deliberately imbalanced: on the 4 096-lane
324/// case the accepted-step count averages **67.9** per lane against a
325/// **maximum of 126**. Each lane costs about **8.6 us** of serial work. Best of 7
326/// samples per point, wall clock for one whole ensemble. Produced by the
327/// `#[ignore]`d `ensemble_crossover_benchmark` test in `parallel/tests.rs` and
328/// transcribed from its printed output. Three independent runs are carried
329/// side by side rather than averaged, because the parallel column is far
330/// noisier than the serial one and the spread is the finding.
331///
332/// | Lanes | serial (run A) | speed-up A | speed-up B | speed-up C |
333/// |---|---|---|---|---|
334/// | 8 | 66.85 us | 2.66x | 1.26x | 0.89x |
335/// | 16 | 132.56 us | 2.99x | 2.10x | 3.12x |
336/// | 32 | 267.99 us | 1.23x | 1.53x | 2.51x |
337/// | 64 | 545.25 us | 3.07x | 1.94x | 2.37x |
338/// | 128 | 1092.75 us | 3.27x | 1.96x | 3.78x |
339/// | 256 | 2212.57 us | 3.67x | 2.41x | 2.99x |
340/// | 1 024 | 8814.43 us | 3.55x | 2.94x | 3.72x |
341/// | 4 096 | 35205.42 us | 3.67x | 3.87x | 3.60x |
342/// | 16 384 | 141229.69 us | 3.86x | 3.89x | 3.74x |
343///
344/// *Result.* **16** is the smallest size at which the parallel path won in all
345/// three runs *and* kept winning at every larger size in all three, and it is
346/// the value this constant takes. At 8 lanes it lost run C (0.89x).
347///
348/// *How firm is that.* Not very, and the table says so honestly. Between 16 and
349/// 128 lanes the **sign** of the effect is consistent — the parallel path never
350/// loses — but the **magnitude** is not resolved: 32 lanes gave 1.23x and 2.51x
351/// in two runs of the same code on the same data. Anywhere in 16–128 would be
352/// defensible on this evidence. What the table does establish firmly is the
353/// plateau: from 1 024 lanes upward the speed-up sits at 3.5–3.9x on 4 logical
354/// cores, run to run, which is close to the ideal and is the expected signature
355/// of a compute-bound kernel with no cross-lane traffic.
356///
357/// *Interpretation.* This is the lowest crossover measured anywhere in the crate
358/// — 16x below [`crate::math::parallel::ROOT_BATCH_MIN_PROBLEMS`] (256), 256x
359/// below the crate-wide placeholder
360/// [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`] (4 096) and 8 192x below
361/// [`crate::fields::parallel::FIELD_PARALLEL_CROSSOVER`] (131 072). The reason
362/// is structural: one lane here is 68 adaptive steps, each of them six
363/// derivative evaluations, against a state vector of one `f64`. At about 8.6 us
364/// per lane it is by a wide margin the most compute-dense per work item of the
365/// crate's measured kernels, so `rayon`'s dispatch cost is amortised almost
366/// immediately. Five kernel families have now been measured in this crate and
367/// they want **16, 256, 4 096, 131 072 and 262 144** — a spread of 16 384x,
368/// which is the strongest evidence yet that no single crate-wide threshold can
369/// be right.
370///
371/// The corollary is the one the root finder also found, sharpened here:
372/// **the crossover is set by the caller's problem, not by this module.** A lane
373/// integrating a short interval, or a two-equation system to a loose tolerance,
374/// is an order of magnitude cheaper and crosses over correspondingly later. A
375/// caller whose lanes are very cheap should pass [`ComputeBackend::Serial`]
376/// explicitly rather than trust this number.
377///
378/// # Contention — this threshold assumes the cores are actually free
379///
380/// Three earlier runs of the same benchmark were taken while an unrelated
381/// `rustc` was using ~234% CPU on the same 4-core machine (load average 5.08),
382/// and they are **not** the table above. Under that load the parallel path lost
383/// at 8 lanes in every run and lost once at 64 lanes, and the plateau speed-up
384/// fell from 3.5–3.9x to 2.9–3.9x. The lesson generalises beyond this
385/// measurement: a threshold measured on an idle machine is optimistic for a
386/// process sharing cores with anything else — an MPI rank per core, a coupled
387/// solver threading elsewhere, or a CI box running several jobs. In those
388/// settings prefer an explicit [`ComputeBackend`].
389///
390/// # Limitations
391///
392/// One machine, four logical cores, one system family (scalar linear decay),
393/// one stepper (`Rkf45`). Not measured on Android/Termux hardware, not on a
394/// many-core server, and not with [`Rosenbrock23`](crate::ode::Rosenbrock23),
395/// whose per-lane cost includes an LU factorisation and is therefore higher
396/// still — meaning this floor is conservative for stiff ensembles rather than
397/// wrong for them.
398///
399/// # Units
400///
401/// A count of independent initial-value problems, dimensionless.
402pub const ODE_ENSEMBLE_MIN_LANES: usize = 16;
403
404/// Interval count below which a [`ComputeBackend::CpuMulti`] request runs
405/// [`quadrature_batch`] and [`adaptive_quadrature_batch`] on the calling thread
406/// instead.
407///
408/// # Measured crossover
409///
410/// *Methodology.* Same machine, build and conditions as
411/// [`ODE_ENSEMBLE_MIN_LANES`] (4 logical cores, release, `--features parallel`,
412/// idle, best of 7 samples, three independent runs), measured 2026-08-13.
413/// Batches of `n` independent integrals of `exp(-a_i x) sin(b_i x)` with
414/// `a_i` in `[0.1, 3)` and `b_i` in `[1, 30)`, over per-lane intervals inside
415/// `[0, 4]`, evaluated with [`QuadratureRule::GaussLegendre`] at
416/// [`GaussOrder::G5`] over 16 panels — 80 integrand evaluations per lane, about
417/// **1.4 us** of serial work, a mid-cost rule. Produced by the `#[ignore]`d
418/// `quadrature_crossover_benchmark` test in `parallel/tests.rs` and transcribed
419/// from its printed output.
420///
421/// | Intervals | serial (run A) | speed-up A | speed-up B | speed-up C |
422/// |---|---|---|---|---|
423/// | 16 | 20.48 us | 1.36x | 1.16x | 0.51x |
424/// | 32 | 40.96 us | 2.03x | 2.10x | 1.81x |
425/// | 64 | 82.62 us | 1.95x | 2.47x | 2.62x |
426/// | 128 | 170.22 us | 1.96x | 1.58x | 2.75x |
427/// | 256 | 351.75 us | 3.15x | 3.47x | 3.29x |
428/// | 1 024 | 1475.98 us | 3.61x | 3.79x | 3.74x |
429/// | 4 096 | 6090.40 us | 3.89x | 3.75x | 3.97x |
430/// | 16 384 | 24717.71 us | 3.89x | 3.75x | 3.90x |
431///
432/// *Result.* **32** is the smallest size at which the parallel path won in all
433/// three runs and kept winning at every larger size, and it is the value this
434/// constant takes. At 16 intervals it lost run C badly (0.51x).
435///
436/// *Interpretation.* Twice the ODE floor of 16, on lanes about 6x cheaper
437/// (1.4 us against 8.6 us). The two do not scale together, which is the honest
438/// reading of a measurement whose 16–128 region is dominated by run-to-run
439/// noise in both kernels; what both agree on is that a batch of a few hundred
440/// compute-dense lanes is comfortably worth threading and a batch of a handful
441/// is not. From 256 intervals upward the speed-up is a stable 3.1–4.0x on 4
442/// logical cores.
443///
444/// **This one floor is shared by both quadrature entry points**, and it was
445/// measured on the fixed-rule path only. [`adaptive_quadrature_batch`] costs
446/// more per lane than any fixed rule a caller is likely to choose — the
447/// verification lanes on that function needed 469 to 1 225 evaluations against
448/// this rule's 80 — so it crosses over *earlier* and inherits this floor as a
449/// conservative assumption rather than a measurement. A caller whose fixed rule
450/// is very cheap — say [`QuadratureRule::Trapezoid`] with one panel, two
451/// evaluations — is well below what was measured and should pass a backend
452/// explicitly.
453///
454/// # Contention
455///
456/// The same caveat as [`ODE_ENSEMBLE_MIN_LANES`], and it bit harder here.
457/// Three earlier runs taken while an unrelated `rustc` held ~234% CPU on the
458/// same 4-core machine showed the parallel path **losing at every size up to
459/// 64** and sitting at 0.98–1.02x — no speed-up at all — at 256 through 4 096,
460/// where the idle machine gives 3.1–4.0x. A threshold measured on an idle
461/// machine is optimistic for a process that shares its cores.
462///
463/// # Limitations
464///
465/// One machine, four logical cores, idle, one integrand family, one rule
466/// (`G5` over 16 panels). Not measured on Android/Termux. Not measured for the
467/// adaptive path, for the trapezoid or Simpson rules, or for other Gauss orders.
468///
469/// # Units
470///
471/// A count of independent definite integrals, dimensionless.
472pub const QUADRATURE_MIN_INTERVALS: usize = 32;
473
474/// Hard ceiling on adaptive bisection depth in
475/// [`adaptive_quadrature_batch`], regardless of
476/// [`AdaptiveSettings::max_subdivisions`].
477///
478/// A depth of 50 halvings shrinks an interval by a factor of `2^50` (about
479/// `10^15`), at which point the sub-interval is at the rounding floor of any
480/// abscissa a caller is likely to pass and further subdivision cannot improve
481/// the answer. The ceiling exists so that a pathological integrand — one with a
482/// genuine singularity inside the interval — terminates with
483/// [`QuadratureStatus::ToleranceNotMet`] instead of consuming memory
484/// indefinitely.
485///
486/// # Units
487///
488/// A count of bisections, dimensionless.
489pub const MAX_ADAPTIVE_DEPTH: u32 = 50;
490
491// ── Backend dispatch ─────────────────────────────────────────────────────────
492
493/// Resolve a requested backend to the one this module will actually run, given
494/// how much work there is.
495///
496/// Three reductions, in order: [`ComputeBackend::resolve`] degrades anything
497/// whose feature is off; `Gpu` degrades again because this module has no GPU
498/// kernel; and `CpuMulti` degrades to `Serial` below `min_work_items`. The
499/// result is only ever `Serial` or `CpuMulti`, and none of the degradations can
500/// change a returned value.
501fn effective_backend(
502 requested: ComputeBackend,
503 work_items: usize,
504 min_work_items: usize,
505) -> ComputeBackend {
506 let cpu = match requested.resolve() {
507 ComputeBackend::Gpu => ComputeBackend::CpuMulti.resolve(),
508 other => other,
509 };
510 match cpu {
511 ComputeBackend::CpuMulti if work_items >= min_work_items => ComputeBackend::CpuMulti,
512 _ => ComputeBackend::Serial,
513 }
514}
515
516/// The [`ComputeBackend`] the ODE ensemble would actually use for `n` lanes if
517/// asked for `requested` — without running anything.
518///
519/// Applies exactly the same reduction the kernels do (feature availability, no
520/// GPU kernel here, and the [`ODE_ENSEMBLE_MIN_LANES`] size floor), so what it
521/// reports is what would run. Useful for logging and for benchmark harnesses.
522///
523/// # Arguments
524///
525/// - `requested` — the backend a caller would pass to [`integrate_ensemble`].
526/// - `n` — the number of independent lanes, dimensionless.
527///
528/// # Returns
529///
530/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`]; never
531/// [`ComputeBackend::Gpu`], because no GPU kernel exists here.
532///
533/// # Example
534///
535/// ```rust
536/// use outram_foam_basic_lib::compute::ComputeBackend;
537/// use outram_foam_basic_lib::ode::parallel::{ensemble_backend_for, ODE_ENSEMBLE_MIN_LANES};
538///
539/// // Too small to thread, whatever was asked for.
540/// assert_eq!(ensemble_backend_for(ComputeBackend::CpuMulti, 4), ComputeBackend::Serial);
541///
542/// // Big enough; the answer now depends only on whether `parallel` is compiled in.
543/// let picked = ensemble_backend_for(ComputeBackend::CpuMulti, ODE_ENSEMBLE_MIN_LANES);
544/// assert!(picked.is_available());
545/// ```
546#[must_use]
547pub fn ensemble_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
548 effective_backend(requested, n, ODE_ENSEMBLE_MIN_LANES)
549}
550
551/// The [`ComputeBackend`] the quadrature kernels would actually use for `n`
552/// intervals if asked for `requested` — without running anything.
553///
554/// The quadrature counterpart of [`ensemble_backend_for`], differing only in
555/// using the [`QUADRATURE_MIN_INTERVALS`] size floor. Both
556/// [`quadrature_batch`] and [`adaptive_quadrature_batch`] use it.
557///
558/// # Arguments
559///
560/// - `requested` — the backend a caller would pass to [`quadrature_batch`].
561/// - `n` — the number of independent definite integrals, dimensionless.
562///
563/// # Returns
564///
565/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`].
566///
567/// # Example
568///
569/// ```rust
570/// use outram_foam_basic_lib::compute::ComputeBackend;
571/// use outram_foam_basic_lib::ode::parallel::quadrature_backend_for;
572///
573/// assert_eq!(quadrature_backend_for(ComputeBackend::CpuMulti, 8), ComputeBackend::Serial);
574/// assert_eq!(quadrature_backend_for(ComputeBackend::Serial, 1 << 20), ComputeBackend::Serial);
575/// ```
576#[must_use]
577pub fn quadrature_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
578 effective_backend(requested, n, QUADRATURE_MIN_INTERVALS)
579}
580
581// ═════════════════════════════════════════════════════════════════════════════
582// Part A — ODE ensembles
583// ═════════════════════════════════════════════════════════════════════════════
584
585/// One member of an ODE ensemble: a system, its initial condition, and the
586/// interval to advance it over.
587///
588/// The system is owned **by value**, so an ensemble is a plain
589/// `Vec<OdeLane<S>>` with no lifetime parameter anywhere and no possibility of
590/// the system slice and the initial-condition slice disagreeing in length. `S`
591/// may be any concrete type implementing [`OdeSystem`], including the caller's
592/// own enum over several systems — which is how one ensemble holds genuinely
593/// different physics in different lanes.
594///
595/// # Units
596///
597/// `x_start`, `x_end` and `dx0` are in the independent variable's units
598/// (typically seconds); `y0`'s components are in whatever units that lane's
599/// state carries, which need not be the same as each other. All are the
600/// caller's own units — see the module-level "Units" section.
601///
602/// # Validity
603///
604/// A lane is rejected with [`OdeLaneStatus::InvalidLane`], before any
605/// integration, unless all of the following hold:
606///
607/// - `y0.len() == system.n_eqns()`
608/// - `x_start`, `x_end` and every component of `y0` are finite
609/// - `dx0` is finite and strictly positive
610/// - `x_end >= x_start` — **the underlying interval loop integrates forwards
611/// only**, so a reversed interval is a caller error rather than a backwards
612/// integration
613///
614/// `x_end == x_start` is legal and is a no-op: the lane completes in zero steps
615/// with its state unchanged.
616#[derive(Debug, Clone, PartialEq)]
617pub struct OdeLane<S: OdeSystem> {
618 /// The system this lane integrates, owned outright.
619 pub system: S,
620 /// Initial state at `x_start`, one entry per equation.
621 pub y0: Vec<f64>,
622 /// Start of the integration interval.
623 pub x_start: f64,
624 /// End of the integration interval; must be `>= x_start`.
625 pub x_end: f64,
626 /// First step size to attempt. Must be finite and `> 0`; the adaptive
627 /// controller adjusts it from there, so it is a starting guess and not a
628 /// constraint.
629 pub dx0: f64,
630}
631
632impl<S: OdeSystem> OdeLane<S> {
633 /// Build one ensemble lane.
634 ///
635 /// # Arguments
636 ///
637 /// - `system` — the system to integrate, moved into the lane.
638 /// - `y0` — initial state, `system.n_eqns()` entries.
639 /// - `x_start`, `x_end` — the interval, in the independent variable's units,
640 /// with `x_end >= x_start`.
641 /// - `dx0` — first step size to attempt, same units, `> 0`.
642 ///
643 /// # Example
644 ///
645 /// ```rust
646 /// use outram_foam_basic_lib::ode::OdeSystem;
647 /// use outram_foam_basic_lib::ode::parallel::OdeLane;
648 ///
649 /// struct Decay;
650 /// impl OdeSystem for Decay {
651 /// fn n_eqns(&self) -> usize { 1 }
652 /// fn derivatives(&self, _x: f64, y: &[f64], d: &mut Vec<f64>) { d[0] = -y[0]; }
653 /// }
654 ///
655 /// let lane = OdeLane::new(Decay, vec![1.0], 0.0, 1.0, 0.1);
656 /// assert_eq!(lane.y0.len(), 1);
657 /// ```
658 pub fn new(system: S, y0: Vec<f64>, x_start: f64, x_end: f64, dx0: f64) -> Self {
659 Self {
660 system,
661 y0,
662 x_start,
663 x_end,
664 dx0,
665 }
666 }
667}
668
669/// How one lane of an ODE ensemble ended.
670///
671/// Only [`Completed`](Self::Completed) means the lane reached `x_end`. Every
672/// other variant is a failure a caller must handle; see the module-level
673/// "Non-convergence is reported, never swallowed" section for the accessors that
674/// make it hard to skip.
675///
676/// | Variant | `last_state` | Meaning |
677/// |---|---|---|
678/// | [`Completed`](Self::Completed) | the state at `x_end` | reached the end of the interval |
679/// | [`MaxStepsExceeded`](Self::MaxStepsExceeded) | genuine partial state at `x_reached` — **not** the answer | ran out of sub-steps |
680/// | [`StepSizeUnderflow`](Self::StepSizeUnderflow) | genuine partial state at `x_reached` | the step shrank below `f64::EPSILON` |
681/// | [`NotFinite`](Self::NotFinite) | the non-finite state | the state went `NaN`/infinite |
682/// | [`InvalidLane`](Self::InvalidLane) | all `NaN` | the lane's inputs were unusable |
683///
684/// The two middle variants are the *stiffness signature*: an explicit stepper on
685/// a stiff lane runs out of budget rather than returning a wrong answer.
686///
687/// # Units
688///
689/// Dimensionless — a status tag.
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
691pub enum OdeLaneStatus {
692 /// The lane reached `x_end` with a finite state.
693 Completed,
694 /// `OdeSolverConfig::max_steps` sub-steps were taken without spanning the
695 /// interval. The reported state is genuine but partial, at
696 /// [`OdeLaneSolution::x_reached`].
697 MaxStepsExceeded,
698 /// The adaptive controller shrank the step below `f64::EPSILON` trying to
699 /// meet the tolerance — the system is too stiff for the chosen stepper, or
700 /// the tolerances are unattainable. Maps to
701 /// [`OdeError::StepSizeUnderflow`](crate::ode::OdeError::StepSizeUnderflow).
702 StepSizeUnderflow,
703 /// The integration returned success but the final state contains a `NaN` or
704 /// an infinity. Reported separately from the two budget failures because it
705 /// means the *model* blew up, not that the stepper ran out of room.
706 NotFinite,
707 /// The lane was rejected before any integration — see [`OdeLane`]'s
708 /// "Validity" section for the exact conditions. The state is all `NaN`,
709 /// because returning the untouched initial condition would look like a
710 /// zero-length integration that succeeded.
711 InvalidLane,
712}
713
714impl OdeLaneStatus {
715 /// Whether this status means the lane produced a usable final state.
716 ///
717 /// # Example
718 ///
719 /// ```rust
720 /// use outram_foam_basic_lib::ode::parallel::OdeLaneStatus;
721 ///
722 /// assert!(OdeLaneStatus::Completed.is_completed());
723 /// assert!(!OdeLaneStatus::MaxStepsExceeded.is_completed());
724 /// ```
725 #[must_use]
726 pub fn is_completed(self) -> bool {
727 matches!(self, Self::Completed)
728 }
729
730 /// A short human-readable label, for log lines and failure reports.
731 #[must_use]
732 pub fn label(self) -> &'static str {
733 match self {
734 Self::Completed => "completed",
735 Self::MaxStepsExceeded => "max-steps-exceeded",
736 Self::StepSizeUnderflow => "step-size-underflow",
737 Self::NotFinite => "not-finite",
738 Self::InvalidLane => "invalid-lane",
739 }
740 }
741}
742
743/// The outcome of a single ensemble lane: its status, its final state, how far
744/// it got and how much work it took.
745///
746/// The fields are private on purpose. The only way to get a state that is
747/// claimed to be the answer is [`Self::state`], which returns `Option<&[f64]>`
748/// and hands back `Some` only for a completed lane. The raw state is available
749/// from [`Self::last_state`], whose name is chosen so that using a failed lane's
750/// partial trajectory is a visible decision in the calling code.
751///
752/// # Units
753///
754/// [`Self::state`] and [`Self::last_state`] carry the lane's own state units;
755/// [`Self::x_reached`] and [`Self::dx_final`] are in the independent variable's
756/// units; [`Self::steps`] is a dimensionless count.
757#[derive(Debug, Clone, PartialEq)]
758pub struct OdeLaneSolution {
759 y: Vec<f64>,
760 x_reached: f64,
761 dx_final: f64,
762 steps: u32,
763 status: OdeLaneStatus,
764}
765
766impl OdeLaneSolution {
767 /// The final state, if this lane reached `x_end`.
768 ///
769 /// `None` for every failure status. This is the accessor to reach for; the
770 /// `Option` is the point.
771 #[must_use]
772 pub fn state(&self) -> Option<&[f64]> {
773 if self.status.is_completed() {
774 Some(&self.y)
775 } else {
776 None
777 }
778 }
779
780 /// The last state the stepper held, completed or not.
781 ///
782 /// **The answer only when [`Self::status`] is
783 /// [`OdeLaneStatus::Completed`].** For
784 /// [`OdeLaneStatus::MaxStepsExceeded`] and
785 /// [`OdeLaneStatus::StepSizeUnderflow`] this is a genuine state on the
786 /// trajectory — the state at [`Self::x_reached`], not at `x_end` — and is
787 /// offered so the caller can restart, sub-cycle, or re-run the lane with a
788 /// stiff stepper. For [`OdeLaneStatus::InvalidLane`] every component is
789 /// `NaN`, because there is no honest number to report.
790 #[must_use]
791 pub fn last_state(&self) -> &[f64] {
792 &self.y
793 }
794
795 /// The independent-variable value the trajectory actually reached.
796 ///
797 /// Equal to the last accepted step's endpoint, which for a completed lane is
798 /// `x_end` up to the accumulated rounding of the step sequence — it is the
799 /// measured value, not `x_end` substituted in. `NaN` for
800 /// [`OdeLaneStatus::InvalidLane`].
801 #[must_use]
802 pub fn x_reached(&self) -> f64 {
803 self.x_reached
804 }
805
806 /// The step size the adaptive controller ended on, in the independent
807 /// variable's units.
808 ///
809 /// Worth carrying forward as the next call's `dx0` when marching an ensemble
810 /// through many intervals: it saves the controller re-discovering the scale
811 /// every interval. `NaN` for [`OdeLaneStatus::InvalidLane`].
812 #[must_use]
813 pub fn dx_final(&self) -> f64 {
814 self.dx_final
815 }
816
817 /// Accepted sub-steps this lane took, dimensionless.
818 ///
819 /// Counts *accepted* steps only; a step rejected by the error controller and
820 /// retried at a smaller size is not counted separately, because the retry
821 /// loop lives inside the stepper. This is the number to look at when
822 /// diagnosing which lanes are dragging an ensemble — a lane taking 50x the
823 /// median is the stiff one.
824 #[must_use]
825 pub fn steps(&self) -> u32 {
826 self.steps
827 }
828
829 /// How this lane ended.
830 #[must_use]
831 pub fn status(&self) -> OdeLaneStatus {
832 self.status
833 }
834
835 /// Whether this lane reached `x_end`.
836 #[must_use]
837 pub fn completed(&self) -> bool {
838 self.status.is_completed()
839 }
840}
841
842/// A batch of `N` lane outcomes, in the same order as the lanes handed in.
843///
844/// Lane `i` of the result corresponds to `lanes[i]`, always — the parallel path
845/// preserves order, so no index bookkeeping is needed.
846///
847/// # Getting states out
848///
849/// - [`Self::states`] / [`Self::into_states`] — all-or-nothing. `Ok` only when
850/// every lane completed; otherwise `Err(`[`OdeEnsembleFailure`]`)`.
851/// - [`Self::lanes`] — per-lane, when the caller wants to handle failures
852/// individually (re-run stiff, sub-cycle, flag the cell).
853///
854/// # Units
855///
856/// See [`OdeLaneSolution`].
857#[derive(Debug, Clone, PartialEq)]
858pub struct OdeEnsemble {
859 lanes: Vec<OdeLaneSolution>,
860}
861
862impl OdeEnsemble {
863 /// Every lane's outcome, in input order.
864 #[must_use]
865 pub fn lanes(&self) -> &[OdeLaneSolution] {
866 &self.lanes
867 }
868
869 /// Consume the ensemble and take the outcomes.
870 #[must_use]
871 pub fn into_lanes(self) -> Vec<OdeLaneSolution> {
872 self.lanes
873 }
874
875 /// Number of lanes, dimensionless.
876 #[must_use]
877 pub fn len(&self) -> usize {
878 self.lanes.len()
879 }
880
881 /// Whether the ensemble has no lanes.
882 #[must_use]
883 pub fn is_empty(&self) -> bool {
884 self.lanes.is_empty()
885 }
886
887 /// Lane `i`'s outcome, or `None` if `i` is out of range.
888 #[must_use]
889 pub fn get(&self, i: usize) -> Option<&OdeLaneSolution> {
890 self.lanes.get(i)
891 }
892
893 /// Whether every lane completed. Vacuously `true` for an empty ensemble.
894 #[must_use]
895 pub fn all_completed(&self) -> bool {
896 self.lanes.iter().all(OdeLaneSolution::completed)
897 }
898
899 /// How many lanes failed, dimensionless.
900 #[must_use]
901 pub fn failure_count(&self) -> usize {
902 self.lanes.iter().filter(|l| !l.completed()).count()
903 }
904
905 /// The first failing lane's index and outcome, if any.
906 ///
907 /// The natural thing to print when an ensemble fails: it names the cell to
908 /// look at rather than just the count.
909 #[must_use]
910 pub fn first_failure(&self) -> Option<(usize, &OdeLaneSolution)> {
911 self.lanes.iter().enumerate().find(|(_, l)| !l.completed())
912 }
913
914 /// Every failing lane, as `(index, outcome)` pairs.
915 ///
916 /// Allocates, so prefer [`Self::first_failure`] or [`Self::failure_count`]
917 /// on a hot path.
918 #[must_use]
919 pub fn failures(&self) -> Vec<(usize, &OdeLaneSolution)> {
920 self.lanes
921 .iter()
922 .enumerate()
923 .filter(|(_, l)| !l.completed())
924 .collect()
925 }
926
927 /// Total accepted sub-steps over every lane, dimensionless.
928 ///
929 /// The honest measure of how much work the ensemble did, and — read beside
930 /// [`Self::max_steps_taken`] — the measure of how imbalanced it was.
931 #[must_use]
932 pub fn total_steps(&self) -> u64 {
933 self.lanes.iter().map(|l| u64::from(l.steps)).sum()
934 }
935
936 /// The largest accepted-sub-step count over all lanes, dimensionless; `0`
937 /// for an empty ensemble.
938 ///
939 /// With [`Self::total_steps`] this quantifies the load imbalance the
940 /// module-level "Load imbalance" section describes: a maximum far above the
941 /// mean is precisely the case that makes work-stealing worth having.
942 #[must_use]
943 pub fn max_steps_taken(&self) -> u32 {
944 self.lanes.iter().map(|l| l.steps).max().unwrap_or(0)
945 }
946
947 /// Every lane's final state, or an error naming the failures — the
948 /// all-or-nothing path.
949 ///
950 /// # Returns
951 ///
952 /// `Ok(v)` with `v[i]` the final state of lane `i`, in that lane's own
953 /// units, when every lane completed. Otherwise
954 /// `Err(`[`OdeEnsembleFailure`]`)` carrying the failure count and the first
955 /// failing lane. **No `Vec` of plausible-looking states is ever returned for
956 /// an ensemble that contained a failure.**
957 ///
958 /// An empty ensemble returns `Ok(vec![])`.
959 ///
960 /// # Errors
961 ///
962 /// [`OdeEnsembleFailure`] when one or more lanes did not complete.
963 pub fn states(&self) -> Result<Vec<Vec<f64>>, OdeEnsembleFailure> {
964 self.check()?;
965 Ok(self.lanes.iter().map(|l| l.y.clone()).collect())
966 }
967
968 /// [`Self::states`] without the clone — consumes the ensemble.
969 ///
970 /// # Errors
971 ///
972 /// [`OdeEnsembleFailure`] when one or more lanes did not complete.
973 pub fn into_states(self) -> Result<Vec<Vec<f64>>, OdeEnsembleFailure> {
974 self.check()?;
975 Ok(self.lanes.into_iter().map(|l| l.y).collect())
976 }
977
978 /// Shared failure check behind [`Self::states`] and [`Self::into_states`].
979 fn check(&self) -> Result<(), OdeEnsembleFailure> {
980 if let Some((i, l)) = self.first_failure() {
981 return Err(OdeEnsembleFailure {
982 total: self.lanes.len(),
983 failure_count: self.failure_count(),
984 first_index: i,
985 first_status: l.status(),
986 first_steps: l.steps(),
987 first_x_reached: l.x_reached(),
988 });
989 }
990 Ok(())
991 }
992}
993
994/// One or more lanes of an [`OdeEnsemble`] did not complete.
995///
996/// Returned by [`OdeEnsemble::states`]. It names both the scale of the problem
997/// (how many of how many) and a specific lane to look at, because "3 of 10 000
998/// cells failed" is only actionable once you know *which* cell and *how far* it
999/// got.
1000///
1001/// # Units
1002///
1003/// Counts and indices are dimensionless; `first_x_reached` is in the independent
1004/// variable's units.
1005#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
1006#[error(
1007 "{failure_count} of {total} ODE lanes did not complete; \
1008 first failure at lane {first_index} with status {first_status:?} \
1009 after {first_steps} steps, reaching x = {first_x_reached}"
1010)]
1011pub struct OdeEnsembleFailure {
1012 /// Number of lanes in the ensemble.
1013 pub total: usize,
1014 /// Number of lanes that did not complete.
1015 pub failure_count: usize,
1016 /// Index of the first failing lane.
1017 pub first_index: usize,
1018 /// Why that lane failed.
1019 pub first_status: OdeLaneStatus,
1020 /// Accepted sub-steps that lane took before giving up.
1021 pub first_steps: u32,
1022 /// The independent-variable value that lane reached.
1023 pub first_x_reached: f64,
1024}
1025
1026// ── ODE ensemble entry points ────────────────────────────────────────────────
1027
1028/// Integrate `N` independent initial-value problems with **one stepper for every
1029/// lane**, on the chosen backend.
1030///
1031/// This is the entry point for the common case: one ODE per cell, per particle,
1032/// or per material point, all of the same character. Each lane gets its own
1033/// clone of `solver`, so no scratch buffer is shared and no lane can perturb
1034/// another.
1035///
1036/// For an ensemble of mixed stiffness, use [`integrate_ensemble_mixed`] — see
1037/// the module-level "Stiffness" section.
1038///
1039/// # Arguments
1040///
1041/// - `lanes` — one [`OdeLane`] per problem. Lane `i` of the result corresponds
1042/// to `lanes[i]`.
1043/// - `solver` — the stepper prototype, cloned once per lane. Its
1044/// [`OdeSolverConfig`](crate::ode::OdeSolverConfig) tolerances and
1045/// `max_steps` apply to every lane. It must have been built for the same
1046/// equation count the lanes' systems report, because the steppers size their
1047/// scratch buffers at construction.
1048/// - `backend` — requested execution backend. What actually runs is
1049/// [`ensemble_backend_for`] applied to it: an unavailable backend degrades,
1050/// `Gpu` degrades (no GPU kernel here), and an ensemble below
1051/// [`ODE_ENSEMBLE_MIN_LANES`] runs serially. None of those changes the answer.
1052///
1053/// # Returns
1054///
1055/// An [`OdeEnsemble`] with one [`OdeLaneSolution`] per lane, in input order.
1056///
1057/// # Determinism
1058///
1059/// Bit-for-bit identical across backends and thread counts, for systems whose
1060/// `derivatives`/`jacobian` are pure deterministic functions of their arguments.
1061/// See the module-level "Determinism" section.
1062///
1063/// # Cost note
1064///
1065/// `solver` is cloned once per lane, which clones its per-equation scratch
1066/// buffers — a handful of small allocations per lane. This is deliberate: it is
1067/// what makes each lane a pure function of its own inputs, and therefore what
1068/// makes the bitwise-identity claim hold without depending on every stepper's
1069/// buffers being write-before-read. On the measured ensemble it is a small
1070/// fraction of the per-lane cost — about 8.6 us of integration per lane over 68
1071/// adaptive steps, see [`ODE_ENSEMBLE_MIN_LANES`] — but a caller integrating a
1072/// *very* short interval per lane would see it, and it has not been measured
1073/// separately from the integration it accompanies.
1074///
1075/// # Panics
1076///
1077/// Panics if `solver` is [`OdeSolver::Rosenbrock23`] and a lane's system does
1078/// not override
1079/// [`OdeSystem::jacobian`](crate::ode::OdeSystem::jacobian), whose default
1080/// implementation panics. On the `CpuMulti` path the panic propagates out of the
1081/// `rayon` scope. Check
1082/// [`OdeSolver::requires_jacobian`](crate::ode::OdeSolver::requires_jacobian)
1083/// first.
1084///
1085/// # Verification
1086///
1087/// *Methodology.* Checked against the closed-form solution of `dy/dx = -k y`,
1088/// `y(0) = 1`, namely `y(x) = exp(-k x)`, over 64 lanes with `k` spread evenly
1089/// across `[0.5, 8]`, integrated to `x = 1` by all three steppers; and against
1090/// the harmonic oscillator `y1' = -y2`, `y2' = y1` from `y(0) = (1, 0)`, whose
1091/// solution is `(cos x, sin x)`, over 16 lanes ending at `m * pi/2` for
1092/// `m = 1..=16`. Tolerances `abs_tol = 1e-10`, `rel_tol = 1e-8` for `Rkf45` and
1093/// `Rosenbrock23` (`1e-12` / `1e-10` for the oscillator) and `1e-3` / `1e-2`
1094/// for `Euler`, which cannot reach the high-order tolerances inside the default
1095/// 10 000-step budget. Pass criteria: `< 1e-8` for the high-order steppers,
1096/// `< 5e-2` for first-order `Euler`.
1097///
1098/// *Results, measured 2026-08-13 by `ensemble_matches_analytic_decay` and
1099/// `ensemble_matches_harmonic_oscillator` in `parallel/tests.rs`, release
1100/// build:* worst absolute error over the 64 decay lanes **1.518896e-9**
1101/// (`Rkf45`, 3 038 total accepted steps, 81 in the worst lane),
1102/// **1.387431e-9** (`Rosenbrock23`, 46 535 steps, 1 186 in the worst lane) and
1103/// **1.297370e-3** (`Euler`, 21 817 steps, 421 in the worst lane); worst
1104/// absolute error on the oscillator **4.905929e-10** (`Rkf45`).
1105///
1106/// *Interpretation.* The two high-order steppers agree with the closed form to
1107/// their requested tolerance and with each other to about 1e-9, while Euler is
1108/// six orders coarser — the expected signature of three genuinely different
1109/// steppers being reached through the ensemble, which is what rules out the
1110/// wrapper silently routing every lane to one of them. The step counts are the
1111/// other half of the story: `Rosenbrock23` needs 15x the steps of `Rkf45` on a
1112/// non-stiff problem to reach the same accuracy, which is exactly why
1113/// [`integrate_ensemble_mixed`] exists rather than "just use the stiff solver
1114/// everywhere".
1115///
1116/// # Example — the `uom` boundary
1117///
1118/// The ensemble is dimensionless, and the caller converts at its edge. These
1119/// lanes are lumped-capacitance bodies cooling towards ambient, `dT/dt =
1120/// -(T - T_inf) / tau`, one lane per time constant, recovering a
1121/// `ThermodynamicTemperature`.
1122///
1123/// **On the tolerance asserted below.** The stepper's `rel_tol` is `1e-8` and
1124/// the states are of order 400 K, so the achievable *absolute* accuracy is a
1125/// few microkelvin — the error floor is set by the controller's tolerance and
1126/// the magnitude of the state, not by the ensemble. Measured 2026-08-13
1127/// (release) by `lumped_body_accuracy_is_set_by_the_relative_tolerance` in
1128/// `parallel/tests.rs`: worst absolute error **1.150937e-6 K** at
1129/// `abs_tol = 1e-10`, `rel_tol = 1e-8`; **1.222958e-8 K** at `1e-12`/`1e-10`;
1130/// **1.289209e-10 K** at `1e-14`/`1e-12`. The bound below is `5e-6 K`, set from
1131/// the first of those measurements — a tighter assertion would be asserting
1132/// something this stepper at this tolerance does not deliver. A caller wanting
1133/// sub-nanokelvin must ask for it through the tolerances, and the third row
1134/// says what that buys.
1135///
1136/// ```rust
1137/// use outram_foam_basic_lib::compute::ComputeBackend;
1138/// use outram_foam_basic_lib::ode::{OdeSolver, OdeSystem};
1139/// use outram_foam_basic_lib::ode::parallel::{integrate_ensemble, OdeLane};
1140/// use uom::si::f64::{ThermodynamicTemperature, Time};
1141/// use uom::si::thermodynamic_temperature::kelvin;
1142/// use uom::si::time::second;
1143///
1144/// /// Lumped body: `dT/dt = -(T - T_inf) / tau`, all in SI base units.
1145/// struct LumpedBody { tau_s: f64, t_inf_k: f64 }
1146/// impl OdeSystem for LumpedBody {
1147/// fn n_eqns(&self) -> usize { 1 }
1148/// fn derivatives(&self, _t: f64, y: &[f64], dydt: &mut Vec<f64>) {
1149/// dydt[0] = -(y[0] - self.t_inf_k) / self.tau_s;
1150/// }
1151/// }
1152///
1153/// // Convert in: typed quantities out to plain f64 in named units.
1154/// let t_inf = ThermodynamicTemperature::new::<kelvin>(300.0);
1155/// let t0 = ThermodynamicTemperature::new::<kelvin>(500.0);
1156/// let horizon = Time::new::<second>(10.0);
1157///
1158/// let lanes: Vec<OdeLane<LumpedBody>> = [5.0_f64, 20.0]
1159/// .iter()
1160/// .map(|&tau| {
1161/// OdeLane::new(
1162/// LumpedBody { tau_s: tau, t_inf_k: t_inf.get::<kelvin>() },
1163/// vec![t0.get::<kelvin>()],
1164/// 0.0,
1165/// horizon.get::<second>(),
1166/// 0.1,
1167/// )
1168/// })
1169/// .collect();
1170///
1171/// let ensemble = integrate_ensemble(
1172/// &lanes,
1173/// &OdeSolver::rkf45(1, 1e-10, 1e-8),
1174/// ComputeBackend::CpuMulti,
1175/// );
1176///
1177/// // Convert out: back to typed temperatures.
1178/// let temperatures: Vec<ThermodynamicTemperature> = ensemble
1179/// .states()
1180/// .expect("both lanes complete")
1181/// .iter()
1182/// .map(|s| ThermodynamicTemperature::new::<kelvin>(s[0]))
1183/// .collect();
1184///
1185/// // Closed form: T(t) = T_inf + (T0 - T_inf) exp(-t / tau). The 5e-6 K bound
1186/// // is the measured floor at rel_tol = 1e-8 on a ~400 K state; see above.
1187/// for (tau, temperature) in [5.0_f64, 20.0].iter().zip(&temperatures) {
1188/// let exact = 300.0 + 200.0 * (-10.0_f64 / tau).exp();
1189/// assert!((temperature.get::<kelvin>() - exact).abs() < 5e-6);
1190/// }
1191/// ```
1192#[must_use]
1193pub fn integrate_ensemble<S>(
1194 lanes: &[OdeLane<S>],
1195 solver: &OdeSolver,
1196 backend: ComputeBackend,
1197) -> OdeEnsemble
1198where
1199 S: OdeSystem + Sync,
1200{
1201 integrate_ensemble_mixed(lanes, |_| solver.clone(), backend)
1202}
1203
1204/// Integrate `N` independent initial-value problems with the **stepper chosen
1205/// per lane**, on the chosen backend.
1206///
1207/// The mixed-stiffness entry point. `solver_of(i)` is called once for lane `i`
1208/// and its return value integrates that lane and no other, so a caller can put
1209/// [`Rosenbrock23`](crate::ode::Rosenbrock23) on the handful of stiff cells and
1210/// leave the rest on [`Rkf45`](crate::ode::Rkf45) — paying the LU factorisation
1211/// only where it is needed.
1212///
1213/// The natural way to use it is as a second pass: run
1214/// [`integrate_ensemble`] with an explicit stepper, read
1215/// [`OdeEnsemble::failures`], and re-run just those lanes stiff.
1216///
1217/// # Arguments
1218///
1219/// - `lanes` — one [`OdeLane`] per problem.
1220/// - `solver_of` — `solver_of(i)` returns the stepper for lane `i`. Called
1221/// exactly once per lane, on whichever thread runs that lane, hence the `Sync`
1222/// bound; the bound is present in both feature builds so that enabling
1223/// `parallel` never changes a public signature. It must be a pure
1224/// deterministic function of `i` — see the module-level "Determinism" section.
1225/// - `backend` — requested backend; see [`ensemble_backend_for`].
1226///
1227/// # Returns
1228///
1229/// An [`OdeEnsemble`] with one [`OdeLaneSolution`] per lane, in input order.
1230///
1231/// # Panics
1232///
1233/// As [`integrate_ensemble`]: a lane given
1234/// [`OdeSolver::Rosenbrock23`] whose system does not implement
1235/// [`OdeSystem::jacobian`](crate::ode::OdeSystem::jacobian) panics.
1236///
1237/// # Example — a stiff lane and a benign one in one ensemble
1238///
1239/// ```rust
1240/// use outram_foam_basic_lib::compute::ComputeBackend;
1241/// use outram_foam_basic_lib::matrix::SquareMatrix;
1242/// use outram_foam_basic_lib::ode::{OdeSolver, OdeSystem};
1243/// use outram_foam_basic_lib::ode::parallel::{integrate_ensemble_mixed, OdeLane};
1244///
1245/// /// `dy/dx = -k y`, with an analytic Jacobian so the stiff stepper can run.
1246/// struct Decay { k: f64 }
1247/// impl OdeSystem for Decay {
1248/// fn n_eqns(&self) -> usize { 1 }
1249/// fn derivatives(&self, _x: f64, y: &[f64], d: &mut Vec<f64>) { d[0] = -self.k * y[0]; }
1250/// fn jacobian(&self, _x: f64, _y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
1251/// dfdx[0] = 0.0;
1252/// dfdy.set(0, 0, -self.k);
1253/// }
1254/// }
1255///
1256/// // Lane 0 is benign (k = 1), lane 1 is stiff (k = 5000).
1257/// let lanes = vec![
1258/// OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 1.0, 0.1),
1259/// OdeLane::new(Decay { k: 5000.0 }, vec![1.0], 0.0, 1.0, 0.1),
1260/// ];
1261///
1262/// let ensemble = integrate_ensemble_mixed(
1263/// &lanes,
1264/// |i| {
1265/// if i == 1 {
1266/// OdeSolver::rosenbrock23(1, 1e-10, 1e-8)
1267/// } else {
1268/// OdeSolver::rkf45(1, 1e-10, 1e-8)
1269/// }
1270/// },
1271/// ComputeBackend::Serial,
1272/// );
1273///
1274/// assert!(ensemble.all_completed());
1275/// let states = ensemble.states().unwrap();
1276/// assert!((states[0][0] - (-1.0_f64).exp()).abs() < 1e-8);
1277/// assert!(states[1][0].abs() < 1e-8); // exp(-5000) underflows to ~0
1278/// ```
1279#[must_use]
1280pub fn integrate_ensemble_mixed<S, G>(
1281 lanes: &[OdeLane<S>],
1282 solver_of: G,
1283 backend: ComputeBackend,
1284) -> OdeEnsemble
1285where
1286 S: OdeSystem + Sync,
1287 G: Fn(usize) -> OdeSolver + Sync,
1288{
1289 integrate_ensemble_min(lanes, solver_of, backend, ODE_ENSEMBLE_MIN_LANES)
1290}
1291
1292/// [`integrate_ensemble_mixed`] with the size floor supplied by the caller.
1293///
1294/// Exists so the crossover benchmark can measure the multi-CPU path *below*
1295/// [`ODE_ENSEMBLE_MIN_LANES`] — the only way to find where the crossover
1296/// actually is — and so the cross-backend bitwise tests are not vacuous on small
1297/// ensembles. Not public: production callers get the measured floor.
1298pub(crate) fn integrate_ensemble_min<S, G>(
1299 lanes: &[OdeLane<S>],
1300 solver_of: G,
1301 backend: ComputeBackend,
1302 min_lanes: usize,
1303) -> OdeEnsemble
1304where
1305 S: OdeSystem + Sync,
1306 G: Fn(usize) -> OdeSolver + Sync,
1307{
1308 let n = lanes.len();
1309 let solutions: Vec<OdeLaneSolution> = match effective_backend(backend, n, min_lanes) {
1310 #[cfg(feature = "parallel")]
1311 ComputeBackend::CpuMulti => lanes
1312 .par_iter()
1313 .enumerate()
1314 // No `min_len` floor: adaptive step counts vary widely (measured mean 67.9,
1315 // maximum 126, on the benchmark ensemble), so the splitter is left free to steal
1316 // down to a single lane.
1317 .map(|(i, lane)| integrate_one_lane(lane, &mut solver_of(i)))
1318 .collect(),
1319 _ => lanes
1320 .iter()
1321 .enumerate()
1322 .map(|(i, lane)| integrate_one_lane(lane, &mut solver_of(i)))
1323 .collect(),
1324 };
1325 OdeEnsemble { lanes: solutions }
1326}
1327
1328/// Integrate one lane — the single per-lane kernel both backends call.
1329///
1330/// Drives the crate's existing [`OdeSolver::solve_step`] through the crate's
1331/// existing `integrate_interval` loop, wrapping the step closure only to count
1332/// accepted steps and record how far the trajectory actually got. No integration
1333/// arithmetic is duplicated here.
1334#[inline]
1335fn integrate_one_lane<S>(lane: &OdeLane<S>, solver: &mut OdeSolver) -> OdeLaneSolution
1336where
1337 S: OdeSystem,
1338{
1339 let n_eqns = lane.system.n_eqns();
1340 let valid = lane.y0.len() == n_eqns
1341 && lane.x_start.is_finite()
1342 && lane.x_end.is_finite()
1343 && lane.dx0.is_finite()
1344 && lane.dx0 > 0.0
1345 && lane.x_end >= lane.x_start
1346 && lane.y0.iter().all(|v| v.is_finite());
1347
1348 if !valid {
1349 return OdeLaneSolution {
1350 y: vec![f64::NAN; lane.y0.len()],
1351 x_reached: f64::NAN,
1352 dx_final: f64::NAN,
1353 steps: 0,
1354 status: OdeLaneStatus::InvalidLane,
1355 };
1356 }
1357
1358 let mut y = lane.y0.clone();
1359 let mut dx = lane.dx0;
1360
1361 // A zero-length interval is a legitimate no-op; `integrate_interval`'s
1362 // `while x < x_end` would do nothing anyway, but saying so explicitly keeps
1363 // the reported `x_reached` honest.
1364 if lane.x_end == lane.x_start {
1365 return OdeLaneSolution {
1366 y,
1367 x_reached: lane.x_start,
1368 dx_final: dx,
1369 steps: 0,
1370 status: OdeLaneStatus::Completed,
1371 };
1372 }
1373
1374 let cfg = solver.config().clone();
1375 let system = &lane.system;
1376 let mut steps: u32 = 0;
1377 let mut x_reached = lane.x_start;
1378
1379 let outcome = super::integrate_interval(
1380 &cfg,
1381 &mut |x, y, dx| {
1382 let result = solver.solve_step(system, x, y, dx);
1383 if result.is_ok() {
1384 steps = steps.saturating_add(1);
1385 x_reached = *x;
1386 }
1387 result
1388 },
1389 lane.x_start,
1390 lane.x_end,
1391 &mut y,
1392 &mut dx,
1393 );
1394
1395 let status = match outcome {
1396 Ok(()) if y.iter().all(|v| v.is_finite()) => OdeLaneStatus::Completed,
1397 Ok(()) => OdeLaneStatus::NotFinite,
1398 Err(OdeError::StepSizeUnderflow) => OdeLaneStatus::StepSizeUnderflow,
1399 Err(OdeError::MaxStepsExceeded(_)) => OdeLaneStatus::MaxStepsExceeded,
1400 // Since bead `op-ad6h` the solver reports a non-finite error estimate
1401 // as its own variant rather than returning `Ok(())` with a NaN state.
1402 // It maps onto the same lane status the `Ok(())`-with-NaN arm above
1403 // already produced, so a caller's handling is unchanged — what changed
1404 // is that the lane is now reported even when the NaN never reaches
1405 // `y` (an infinite intermediate that the state itself does not record).
1406 Err(OdeError::NonFiniteState) => OdeLaneStatus::NotFinite,
1407 };
1408
1409 OdeLaneSolution {
1410 y,
1411 x_reached,
1412 dx_final: dx,
1413 steps,
1414 status,
1415 }
1416}
1417
1418// ═════════════════════════════════════════════════════════════════════════════
1419// Part B — batched quadrature
1420// ═════════════════════════════════════════════════════════════════════════════
1421
1422/// One lane of a quadrature batch: the limits of one definite integral.
1423///
1424/// # Units
1425///
1426/// `a` and `b` are in the integration variable's units. The value the batch
1427/// returns carries the product of those units and the integrand's.
1428///
1429/// # Conventions
1430///
1431/// - `a == b` integrates to exactly `0.0`, with no integrand evaluations.
1432/// - `b < a` is **supported** and returns the negated integral over `[b, a]`,
1433/// the usual orientation convention. (The ODE half deliberately does *not*
1434/// accept a reversed interval, because its underlying loop marches forwards
1435/// only; quadrature has no such constraint.)
1436/// - A non-finite `a` or `b` yields [`QuadratureStatus::InvalidInterval`]. There
1437/// is no support for infinite limits; a caller wanting one should substitute
1438/// the variable itself, which is the only way to choose the transformation
1439/// knowingly.
1440#[derive(Debug, Clone, Copy, PartialEq)]
1441pub struct QuadratureInterval {
1442 /// Lower limit, in the integration variable's units.
1443 pub a: f64,
1444 /// Upper limit, in the integration variable's units.
1445 pub b: f64,
1446}
1447
1448impl QuadratureInterval {
1449 /// Build an interval `[a, b]`.
1450 ///
1451 /// # Example
1452 ///
1453 /// ```rust
1454 /// use outram_foam_basic_lib::ode::parallel::QuadratureInterval;
1455 ///
1456 /// let iv = QuadratureInterval::new(0.0, 1.0);
1457 /// assert_eq!(iv.b - iv.a, 1.0);
1458 /// ```
1459 #[must_use]
1460 pub fn new(a: f64, b: f64) -> Self {
1461 Self { a, b }
1462 }
1463}
1464
1465/// Node count of a fixed-order Gauss-Legendre rule.
1466///
1467/// A closed set rather than an open `usize`, so the choice is exhaustive at
1468/// every match site and rust-analyzer lists the options on hover — and so the
1469/// nodes can be validated once per order rather than for whatever number a
1470/// caller happens to pass.
1471///
1472/// An `n`-point Gauss-Legendre rule integrates any polynomial of degree
1473/// `2n - 1` or less **exactly** (to rounding), which is both the reason to
1474/// prefer it over Simpson at equal cost and the property the tests use as their
1475/// oracle.
1476///
1477/// | Order | Nodes | Exact to degree |
1478/// |---|---|---|
1479/// | [`G2`](Self::G2) | 2 | 3 |
1480/// | [`G3`](Self::G3) | 3 | 5 |
1481/// | [`G4`](Self::G4) | 4 | 7 |
1482/// | [`G5`](Self::G5) | 5 | 9 |
1483/// | [`G8`](Self::G8) | 8 | 15 |
1484///
1485/// # Units
1486///
1487/// Dimensionless — a mode selector.
1488#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1489pub enum GaussOrder {
1490 /// Two-point rule, exact to cubics.
1491 G2,
1492 /// Three-point rule, exact to quintics.
1493 G3,
1494 /// Four-point rule, exact to degree 7.
1495 #[default]
1496 G4,
1497 /// Five-point rule, exact to degree 9.
1498 G5,
1499 /// Eight-point rule, exact to degree 15.
1500 G8,
1501}
1502
1503impl GaussOrder {
1504 /// Number of nodes in the rule, dimensionless.
1505 #[must_use]
1506 pub const fn points(self) -> usize {
1507 match self {
1508 Self::G2 => 2,
1509 Self::G3 => 3,
1510 Self::G4 => 4,
1511 Self::G5 => 5,
1512 Self::G8 => 8,
1513 }
1514 }
1515
1516 /// The highest polynomial degree this rule integrates exactly, `2n - 1`.
1517 ///
1518 /// # Example
1519 ///
1520 /// ```rust
1521 /// use outram_foam_basic_lib::ode::parallel::GaussOrder;
1522 ///
1523 /// assert_eq!(GaussOrder::G5.points(), 5);
1524 /// assert_eq!(GaussOrder::G5.exact_degree(), 9);
1525 /// ```
1526 #[must_use]
1527 pub const fn exact_degree(self) -> usize {
1528 2 * self.points() - 1
1529 }
1530}
1531
1532/// Which fixed rule [`quadrature_batch`] applies to every lane.
1533///
1534/// All three are **composite** rules over equal subintervals of each lane's own
1535/// interval, and all three cost a fixed, data-independent number of integrand
1536/// evaluations per lane — which is what makes them branch-free, and the only
1537/// part of this module that would map cleanly onto a GPU.
1538///
1539/// A `panels` count of `0` is treated as `1`; there is no error path for it,
1540/// because it is a compile-time-shaped parameter rather than data.
1541///
1542/// | Variant | Evaluations per lane | Error order | Exact for |
1543/// |---|---|---|---|
1544/// | [`Trapezoid`](Self::Trapezoid) | `panels + 1` | `O(h^2)` | linear integrands |
1545/// | [`Simpson`](Self::Simpson) | `2 * panels + 1` | `O(h^4)` | cubics |
1546/// | [`GaussLegendre`](Self::GaussLegendre) | `panels * order` | `O(h^(2n))` | degree `2n - 1` |
1547///
1548/// # Relationship to the workspace's other Simpson
1549///
1550/// `outram-park-fork-dwsim-libs`'s `simpson_integrate` is a faithful port of
1551/// OPEM's rule *including* its documented flaw: it applies the `1, 4, 2, ..., 4,
1552/// 1` weights without requiring an odd sample count, which silently degrades the
1553/// order to `O(h)` for an even one. [`Simpson`](Self::Simpson) here counts
1554/// **panels of two subintervals each**, so the sample count is odd by
1555/// construction and that failure cannot occur. The composite-over-equal-
1556/// subintervals convention is shared; the sample-slice interface and the bug are
1557/// not.
1558///
1559/// # Units
1560///
1561/// Dimensionless — a mode selector plus a count.
1562#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1563pub enum QuadratureRule {
1564 /// Composite trapezoid over `panels` equal subintervals.
1565 ///
1566 /// The cheapest rule and the only one worth using when the integrand is
1567 /// sampled rather than smooth. Second-order accurate.
1568 Trapezoid {
1569 /// Number of equal subintervals, `>= 1` (`0` is treated as `1`).
1570 panels: usize,
1571 },
1572 /// Composite Simpson over `panels` panels, each spanning **two** equal
1573 /// subintervals, so `2 * panels` subintervals in total.
1574 ///
1575 /// Fourth-order accurate and exact for cubics. Counting panels rather than
1576 /// samples is what makes the even-sample-count error impossible.
1577 Simpson {
1578 /// Number of two-subinterval Simpson panels, `>= 1` (`0` is treated as
1579 /// `1`).
1580 panels: usize,
1581 },
1582 /// Composite Gauss-Legendre: `panels` equal subintervals, each integrated by
1583 /// an `order`-point rule.
1584 ///
1585 /// The best accuracy per evaluation for a smooth integrand, and the rule to
1586 /// reach for by default. Gauss rules never evaluate the interval endpoints,
1587 /// so an integrand that is unbounded but integrable at `a` or `b` is handled
1588 /// without special-casing — the same property `raffles` relies on for
1589 /// quantile-function moments.
1590 GaussLegendre {
1591 /// Nodes per subinterval.
1592 order: GaussOrder,
1593 /// Number of equal subintervals, `>= 1` (`0` is treated as `1`).
1594 panels: usize,
1595 },
1596}
1597
1598impl QuadratureRule {
1599 /// Integrand evaluations this rule performs per lane, dimensionless.
1600 ///
1601 /// Fixed and data-independent — that is the defining property of this enum.
1602 ///
1603 /// # Example
1604 ///
1605 /// ```rust
1606 /// use outram_foam_basic_lib::ode::parallel::{GaussOrder, QuadratureRule};
1607 ///
1608 /// assert_eq!(QuadratureRule::Trapezoid { panels: 4 }.evaluations(), 5);
1609 /// assert_eq!(QuadratureRule::Simpson { panels: 4 }.evaluations(), 9);
1610 /// assert_eq!(
1611 /// QuadratureRule::GaussLegendre { order: GaussOrder::G5, panels: 4 }.evaluations(),
1612 /// 20
1613 /// );
1614 /// ```
1615 #[must_use]
1616 pub const fn evaluations(self) -> usize {
1617 match self {
1618 Self::Trapezoid { panels } => panels_of(panels) + 1,
1619 Self::Simpson { panels } => 2 * panels_of(panels) + 1,
1620 Self::GaussLegendre { order, panels } => panels_of(panels) * order.points(),
1621 }
1622 }
1623
1624 /// A short human-readable label, for benchmark tables and log lines.
1625 #[must_use]
1626 pub const fn label(self) -> &'static str {
1627 match self {
1628 Self::Trapezoid { .. } => "trapezoid",
1629 Self::Simpson { .. } => "simpson",
1630 Self::GaussLegendre { .. } => "gauss-legendre",
1631 }
1632 }
1633}
1634
1635/// `panels`, floored at 1 — the one place the "`0` means `1`" convention lives.
1636const fn panels_of(panels: usize) -> usize {
1637 if panels == 0 {
1638 1
1639 } else {
1640 panels
1641 }
1642}
1643
1644/// Tolerances and work limit for [`adaptive_quadrature_batch`].
1645///
1646/// # Units
1647///
1648/// `abs_tol` is in the units of the *integral* (integrand times abscissa);
1649/// `rel_tol` is a dimensionless ratio; `max_subdivisions` is a count.
1650///
1651/// # Example
1652///
1653/// ```rust
1654/// use outram_foam_basic_lib::ode::parallel::AdaptiveSettings;
1655///
1656/// // Struct-update syntax keeps the defaults you did not mean to change.
1657/// let s = AdaptiveSettings { abs_tol: 1e-12, ..AdaptiveSettings::default() };
1658/// assert_eq!(s.rel_tol, 1e-8);
1659/// ```
1660#[derive(Debug, Clone, Copy, PartialEq)]
1661pub struct AdaptiveSettings {
1662 /// Absolute tolerance on the whole integral, in the integral's units.
1663 pub abs_tol: f64,
1664 /// Relative tolerance, dimensionless, applied against the running estimate
1665 /// of the integral's magnitude.
1666 pub rel_tol: f64,
1667 /// Maximum bisections per lane before reporting
1668 /// [`QuadratureStatus::ToleranceNotMet`]. Also bounded by
1669 /// [`MAX_ADAPTIVE_DEPTH`] on depth.
1670 pub max_subdivisions: u32,
1671}
1672
1673impl Default for AdaptiveSettings {
1674 fn default() -> Self {
1675 Self {
1676 abs_tol: 1e-10,
1677 rel_tol: 1e-8,
1678 max_subdivisions: 1_000,
1679 }
1680 }
1681}
1682
1683/// How one lane of a quadrature batch ended.
1684///
1685/// | Variant | `last_value` | Meaning |
1686/// |---|---|---|
1687/// | [`Evaluated`](Self::Evaluated) | the integral | the rule ran to completion |
1688/// | [`ToleranceNotMet`](Self::ToleranceNotMet) | best estimate — **not** to tolerance | adaptive lane ran out of subdivisions |
1689/// | [`NotFinite`](Self::NotFinite) | `NaN` | the accumulated value was `NaN` or infinite |
1690/// | [`InvalidInterval`](Self::InvalidInterval) | `NaN` | a limit is not finite |
1691///
1692/// # What `Evaluated` does and does not claim
1693///
1694/// For [`adaptive_quadrature_batch`] it means the requested tolerance was met.
1695/// For a fixed [`QuadratureRule`] it means only that every node evaluated to a
1696/// finite number and the sum is finite — **it makes no accuracy claim at all**,
1697/// because a fixed rule has no way to know. Choosing enough panels for the
1698/// integrand is the caller's responsibility, and
1699/// [`QuadratureSolution::error_estimate`] is `NaN` on that path for exactly this
1700/// reason.
1701///
1702/// # Units
1703///
1704/// Dimensionless — a status tag.
1705#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1706pub enum QuadratureStatus {
1707 /// The rule ran to completion with a finite result.
1708 Evaluated,
1709 /// Adaptive only: the subdivision budget or [`MAX_ADAPTIVE_DEPTH`] was
1710 /// reached before the tolerance. The reported value is the best estimate
1711 /// available and is **not** claimed to meet the tolerance.
1712 ToleranceNotMet,
1713 /// The accumulated value is `NaN` or infinite — either the integrand
1714 /// returned a non-finite sample, or the sum overflowed.
1715 NotFinite,
1716 /// A limit of the interval was infinite or `NaN`, so there is nothing to
1717 /// integrate over.
1718 InvalidInterval,
1719}
1720
1721impl QuadratureStatus {
1722 /// Whether this status means the lane produced a usable value.
1723 ///
1724 /// # Example
1725 ///
1726 /// ```rust
1727 /// use outram_foam_basic_lib::ode::parallel::QuadratureStatus;
1728 ///
1729 /// assert!(QuadratureStatus::Evaluated.is_evaluated());
1730 /// assert!(!QuadratureStatus::ToleranceNotMet.is_evaluated());
1731 /// ```
1732 #[must_use]
1733 pub fn is_evaluated(self) -> bool {
1734 matches!(self, Self::Evaluated)
1735 }
1736
1737 /// A short human-readable label, for log lines and failure reports.
1738 #[must_use]
1739 pub fn label(self) -> &'static str {
1740 match self {
1741 Self::Evaluated => "evaluated",
1742 Self::ToleranceNotMet => "tolerance-not-met",
1743 Self::NotFinite => "not-finite",
1744 Self::InvalidInterval => "invalid-interval",
1745 }
1746 }
1747}
1748
1749/// The outcome of a single quadrature lane.
1750///
1751/// The fields are private on purpose, on the same reasoning as
1752/// [`OdeLaneSolution`]: [`Self::value`] returns `Option<f64>` and hands back
1753/// `Some` only for a lane that ran to completion, while the raw number is behind
1754/// the deliberately-named [`Self::last_value`].
1755///
1756/// `Copy`, so it can be read out of a [`QuadratureBatch`] without cloning.
1757///
1758/// # Units
1759///
1760/// [`Self::value`], [`Self::last_value`] and [`Self::error_estimate`] are in the
1761/// integral's units (integrand times abscissa); [`Self::evaluations`] is a
1762/// dimensionless count.
1763#[derive(Debug, Clone, Copy, PartialEq)]
1764pub struct QuadratureSolution {
1765 value: f64,
1766 error_estimate: f64,
1767 evaluations: u32,
1768 status: QuadratureStatus,
1769}
1770
1771impl QuadratureSolution {
1772 /// The integral, if this lane ran to completion.
1773 ///
1774 /// `None` for every failure status. This is the accessor to reach for; the
1775 /// `Option` is the point.
1776 #[must_use]
1777 pub fn value(&self) -> Option<f64> {
1778 if self.status.is_evaluated() {
1779 Some(self.value)
1780 } else {
1781 None
1782 }
1783 }
1784
1785 /// The last value the rule accumulated, complete or not.
1786 ///
1787 /// **The integral only when [`Self::status`] is
1788 /// [`QuadratureStatus::Evaluated`].** For
1789 /// [`QuadratureStatus::ToleranceNotMet`] this is the best estimate the
1790 /// adaptive lane reached and is offered for diagnosis — for deciding whether
1791 /// the lane was close or wildly off. For the other two statuses it is `NaN`,
1792 /// because there is no honest number to report.
1793 #[must_use]
1794 pub fn last_value(&self) -> f64 {
1795 self.value
1796 }
1797
1798 /// Estimated absolute error, in the integral's units.
1799 ///
1800 /// Meaningful **only** for [`adaptive_quadrature_batch`], where it is the
1801 /// summed Richardson estimate `|S_2 - S_1| / 15` over the accepted
1802 /// sub-intervals. `NaN` for every fixed [`QuadratureRule`], because a fixed
1803 /// rule has no error estimate to give and returning `0.0` would be a
1804 /// falsehood.
1805 #[must_use]
1806 pub fn error_estimate(&self) -> f64 {
1807 self.error_estimate
1808 }
1809
1810 /// Integrand evaluations this lane performed, dimensionless.
1811 ///
1812 /// Fixed and predictable for a [`QuadratureRule`]; data-dependent for the
1813 /// adaptive path, where it is the honest measure of how hard the integrand
1814 /// was and the source of the load imbalance work-stealing exists to absorb.
1815 #[must_use]
1816 pub fn evaluations(&self) -> u32 {
1817 self.evaluations
1818 }
1819
1820 /// How this lane ended.
1821 #[must_use]
1822 pub fn status(&self) -> QuadratureStatus {
1823 self.status
1824 }
1825
1826 /// Whether this lane produced a usable value.
1827 #[must_use]
1828 pub fn evaluated(&self) -> bool {
1829 self.status.is_evaluated()
1830 }
1831
1832 /// A failed lane with no honest number to report.
1833 #[inline]
1834 fn failed(status: QuadratureStatus, evaluations: u32) -> Self {
1835 Self {
1836 value: f64::NAN,
1837 error_estimate: f64::NAN,
1838 evaluations,
1839 status,
1840 }
1841 }
1842}
1843
1844/// A batch of `N` quadrature outcomes, in the same order as the intervals handed
1845/// in.
1846///
1847/// Lane `i` of the result corresponds to `intervals[i]`, always — the parallel
1848/// path preserves order.
1849///
1850/// # Getting values out
1851///
1852/// - [`Self::values`] — all-or-nothing. `Ok(Vec<f64>)` only when every lane ran
1853/// to completion; otherwise `Err(`[`QuadratureBatchFailure`]`)`.
1854/// - [`Self::solutions`] — per-lane, when the caller wants to handle failures
1855/// individually.
1856///
1857/// # Units
1858///
1859/// See [`QuadratureSolution`].
1860#[derive(Debug, Clone, PartialEq)]
1861pub struct QuadratureBatch {
1862 solutions: Vec<QuadratureSolution>,
1863}
1864
1865impl QuadratureBatch {
1866 /// Every lane's outcome, in input order.
1867 #[must_use]
1868 pub fn solutions(&self) -> &[QuadratureSolution] {
1869 &self.solutions
1870 }
1871
1872 /// Consume the batch and take the outcomes.
1873 #[must_use]
1874 pub fn into_solutions(self) -> Vec<QuadratureSolution> {
1875 self.solutions
1876 }
1877
1878 /// Number of lanes, dimensionless.
1879 #[must_use]
1880 pub fn len(&self) -> usize {
1881 self.solutions.len()
1882 }
1883
1884 /// Whether the batch has no lanes.
1885 #[must_use]
1886 pub fn is_empty(&self) -> bool {
1887 self.solutions.is_empty()
1888 }
1889
1890 /// Lane `i`'s outcome, or `None` if `i` is out of range.
1891 #[must_use]
1892 pub fn get(&self, i: usize) -> Option<QuadratureSolution> {
1893 self.solutions.get(i).copied()
1894 }
1895
1896 /// Whether every lane ran to completion. Vacuously `true` when empty.
1897 #[must_use]
1898 pub fn all_evaluated(&self) -> bool {
1899 self.solutions.iter().all(QuadratureSolution::evaluated)
1900 }
1901
1902 /// How many lanes failed, dimensionless.
1903 #[must_use]
1904 pub fn failure_count(&self) -> usize {
1905 self.solutions.iter().filter(|s| !s.evaluated()).count()
1906 }
1907
1908 /// The first failing lane and its outcome, if any.
1909 #[must_use]
1910 pub fn first_failure(&self) -> Option<(usize, QuadratureSolution)> {
1911 self.solutions
1912 .iter()
1913 .enumerate()
1914 .find(|(_, s)| !s.evaluated())
1915 .map(|(i, s)| (i, *s))
1916 }
1917
1918 /// Every failing lane, as `(index, outcome)` pairs.
1919 ///
1920 /// Allocates, so prefer [`Self::first_failure`] on a hot path.
1921 #[must_use]
1922 pub fn failures(&self) -> Vec<(usize, QuadratureSolution)> {
1923 self.solutions
1924 .iter()
1925 .enumerate()
1926 .filter(|(_, s)| !s.evaluated())
1927 .map(|(i, s)| (i, *s))
1928 .collect()
1929 }
1930
1931 /// Total integrand evaluations over every lane, dimensionless.
1932 #[must_use]
1933 pub fn total_evaluations(&self) -> u64 {
1934 self.solutions
1935 .iter()
1936 .map(|s| u64::from(s.evaluations))
1937 .sum()
1938 }
1939
1940 /// All integrals, or an error naming the failures — the all-or-nothing path.
1941 ///
1942 /// # Returns
1943 ///
1944 /// `Ok(v)` with `v[i]` the integral over `intervals[i]`, in the integral's
1945 /// units, when every lane ran to completion. Otherwise
1946 /// `Err(`[`QuadratureBatchFailure`]`)`. **No `Vec` of plausible-looking
1947 /// numbers is ever returned for a batch that contained a failure.**
1948 ///
1949 /// An empty batch returns `Ok(vec![])`.
1950 ///
1951 /// # Errors
1952 ///
1953 /// [`QuadratureBatchFailure`] when one or more lanes failed.
1954 ///
1955 /// # Example
1956 ///
1957 /// ```rust
1958 /// use outram_foam_basic_lib::compute::ComputeBackend;
1959 /// use outram_foam_basic_lib::ode::parallel::{
1960 /// quadrature_batch, QuadratureInterval, QuadratureRule, QuadratureStatus,
1961 /// };
1962 ///
1963 /// // Lane 1's upper limit is not finite.
1964 /// let intervals = [
1965 /// QuadratureInterval::new(0.0, 1.0),
1966 /// QuadratureInterval::new(0.0, f64::INFINITY),
1967 /// ];
1968 /// let batch = quadrature_batch(
1969 /// &intervals,
1970 /// QuadratureRule::Simpson { panels: 8 },
1971 /// ComputeBackend::Serial,
1972 /// |_, x| x * x,
1973 /// );
1974 ///
1975 /// let err = batch.values().expect_err("lane 1 has no finite interval");
1976 /// assert_eq!(err.failure_count, 1);
1977 /// assert_eq!(err.first_index, 1);
1978 /// assert_eq!(err.first_status, QuadratureStatus::InvalidInterval);
1979 ///
1980 /// // The good lane is still individually readable, and Simpson is exact
1981 /// // for x^2.
1982 /// let v = batch.solutions()[0].value().unwrap();
1983 /// assert!((v - 1.0 / 3.0).abs() < 1e-15);
1984 /// assert!(batch.solutions()[1].value().is_none());
1985 /// ```
1986 pub fn values(&self) -> Result<Vec<f64>, QuadratureBatchFailure> {
1987 if let Some((i, s)) = self.first_failure() {
1988 return Err(QuadratureBatchFailure {
1989 total: self.solutions.len(),
1990 failure_count: self.failure_count(),
1991 first_index: i,
1992 first_status: s.status(),
1993 first_evaluations: s.evaluations(),
1994 });
1995 }
1996 Ok(self.solutions.iter().map(|s| s.value).collect())
1997 }
1998}
1999
2000/// One or more lanes of a [`QuadratureBatch`] failed.
2001///
2002/// Returned by [`QuadratureBatch::values`]. As [`OdeEnsembleFailure`], it names
2003/// both the scale of the problem and a specific lane to look at.
2004///
2005/// # Units
2006///
2007/// All counts and indices are dimensionless.
2008#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
2009#[error(
2010 "{failure_count} of {total} quadrature lanes failed; \
2011 first failure at lane {first_index} with status {first_status:?} \
2012 after {first_evaluations} integrand evaluations"
2013)]
2014pub struct QuadratureBatchFailure {
2015 /// Number of lanes in the batch.
2016 pub total: usize,
2017 /// Number of lanes that failed.
2018 pub failure_count: usize,
2019 /// Index of the first failing lane.
2020 pub first_index: usize,
2021 /// Why that lane failed.
2022 pub first_status: QuadratureStatus,
2023 /// Integrand evaluations that lane performed.
2024 pub first_evaluations: u32,
2025}
2026
2027// ── Quadrature entry points ──────────────────────────────────────────────────
2028
2029/// Evaluate `N` independent definite integrals with a **fixed rule**, on the
2030/// chosen backend.
2031///
2032/// The GPU-shaped half of this module: every lane performs exactly
2033/// [`QuadratureRule::evaluations`] integrand calls with no data-dependent
2034/// branching. Use it when the integrand is smooth and the panel count can be
2035/// chosen once for the whole batch — a band-averaged cross section, a
2036/// face-integrated flux, a cell-integrated source term.
2037///
2038/// # Arguments
2039///
2040/// - `intervals` — one [`QuadratureInterval`] per lane.
2041/// - `rule` — the rule applied to every lane. [`QuadratureRule::GaussLegendre`]
2042/// is the default worth reaching for on a smooth integrand.
2043/// - `backend` — requested backend. What actually runs is
2044/// [`quadrature_backend_for`] applied to it; a batch below
2045/// [`QUADRATURE_MIN_INTERVALS`] runs serially. None of the degradations
2046/// changes the answer.
2047/// - `f` — the integrand. `f(i, x)` must return lane `i`'s integrand at abscissa
2048/// `x`. It **must be a pure deterministic function of its arguments** — see
2049/// the module-level "Determinism" section. It is called from multiple threads
2050/// on the `CpuMulti` path, hence the `Sync` bound; the bound is present in
2051/// both feature builds so that enabling `parallel` never changes a public
2052/// signature.
2053///
2054/// # Returns
2055///
2056/// A [`QuadratureBatch`] with one [`QuadratureSolution`] per interval, in input
2057/// order.
2058///
2059/// # Determinism
2060///
2061/// Bit-for-bit identical across backends and thread counts. The sum within a
2062/// lane is sequential and is never split across threads — see the module-level
2063/// "Determinism" section for why that restriction is deliberate.
2064///
2065/// # Verification
2066///
2067/// *Methodology.* Three oracles, all exact rather than another implementation.
2068/// (1) *Polynomial exactness*: an `n`-point Gauss-Legendre rule must integrate
2069/// every monomial `x^d` for `d <= 2n - 1` exactly, Simpson must be exact for
2070/// `d <= 3` and trapezoid for `d <= 1`; checked over `[0, 1]` and `[-2, 3]`
2071/// against the closed form `(b^(d+1) - a^(d+1)) / (d + 1)`. (2) *Published
2072/// nodes*: the computed 8-point nodes and weights against the Abramowitz &
2073/// Stegun 25.4.30 values already carried in this workspace by
2074/// `crates/raffles/src/distributions.rs`. (3) *Transcendental reference*:
2075/// `integral of exp(-x) sin(x) from 0 to pi = (1 + exp(-pi)) / 2`. Pass
2076/// criteria: `< 1e-13` relative for exactness, `< 1e-15` absolute against A&S,
2077/// `< 1e-12` absolute for the transcendental.
2078///
2079/// *Results, measured 2026-08-13 by `gauss_legendre_is_exact_to_its_degree`,
2080/// `gauss_nodes_match_the_in_workspace_abramowitz_stegun_values`,
2081/// `simpson_and_trapezoid_are_exact_to_their_degree` and
2082/// `fixed_rules_match_a_transcendental_reference` in `parallel/tests.rs`,
2083/// release build:*
2084///
2085/// - Exactness sweep (`G2`..`G8`, degrees 0 to 15, both intervals): worst
2086/// relative error **5.769990e-16**. Simpson over degrees 0 to 3:
2087/// **3.552714e-16**. Trapezoid over degrees 0 to 1: **0.000000e0**.
2088/// - Computed `G8` against A&S 25.4.30: worst node difference
2089/// **1.110223e-16**, worst weight difference **1.249001e-16**, weights
2090/// summing to **2.00000000000000000**.
2091/// - Transcendental reference, closed form **0.52160695913188615**: `G8` over 8
2092/// panels (64 evaluations) gave **0.52160695913188615**, error
2093/// **0.000000e0**; Simpson over 64 panels (129 evaluations) error
2094/// **4.206809e-9**; trapezoid over 128 panels (129 evaluations) error
2095/// **5.236767e-5**.
2096///
2097/// *Interpretation.* The Gauss nodes and weights are correct to the last bit
2098/// `f64` holds — agreeing with a published table to within one unit in the last
2099/// place while being computed independently of it — the composite mapping onto
2100/// arbitrary intervals is correct, and the order hierarchy behaves as theory
2101/// requires: `G8` reaches the rounding floor on a smooth transcendental at half
2102/// the evaluations where a 64-panel Simpson is still nine orders away and a
2103/// trapezoid four orders beyond that.
2104///
2105/// # Example — the `uom` boundary
2106///
2107/// The batch is dimensionless, and the caller converts at its edge. These lanes
2108/// integrate a linearly-ramping electrical power over time to recover an
2109/// `Energy`:
2110///
2111/// ```rust
2112/// use outram_foam_basic_lib::compute::ComputeBackend;
2113/// use outram_foam_basic_lib::ode::parallel::{
2114/// quadrature_batch, GaussOrder, QuadratureInterval, QuadratureRule,
2115/// };
2116/// use uom::si::f64::{Energy, Power, Time};
2117/// use uom::si::energy::joule;
2118/// use uom::si::power::watt;
2119/// use uom::si::time::second;
2120///
2121/// // P(t) = P0 (1 - t / T), shutting down linearly over the window.
2122/// let p0 = Power::new::<watt>(2.0e6);
2123/// let window = Time::new::<second>(30.0);
2124///
2125/// // Convert in: limits in seconds, as plain f64.
2126/// let intervals = [
2127/// QuadratureInterval::new(0.0, window.get::<second>()),
2128/// QuadratureInterval::new(0.0, 0.5 * window.get::<second>()),
2129/// ];
2130///
2131/// let batch = quadrature_batch(
2132/// &intervals,
2133/// QuadratureRule::GaussLegendre { order: GaussOrder::G4, panels: 4 },
2134/// ComputeBackend::CpuMulti,
2135/// |_, t| p0.get::<watt>() * (1.0 - t / window.get::<second>()),
2136/// );
2137///
2138/// // Convert out: back to typed energies.
2139/// let energies: Vec<Energy> = batch
2140/// .values()
2141/// .expect("both lanes evaluate")
2142/// .into_iter()
2143/// .map(Energy::new::<joule>)
2144/// .collect();
2145///
2146/// // Closed form over [0, T] is P0 T / 2; over [0, T/2] it is 3 P0 T / 8.
2147/// assert!((energies[0].get::<joule>() - 0.5 * 2.0e6 * 30.0).abs() < 1e-6);
2148/// assert!((energies[1].get::<joule>() - 0.375 * 2.0e6 * 30.0).abs() < 1e-6);
2149/// ```
2150#[must_use]
2151pub fn quadrature_batch<F>(
2152 intervals: &[QuadratureInterval],
2153 rule: QuadratureRule,
2154 backend: ComputeBackend,
2155 f: F,
2156) -> QuadratureBatch
2157where
2158 F: Fn(usize, f64) -> f64 + Sync,
2159{
2160 quadrature_batch_min(intervals, rule, backend, QUADRATURE_MIN_INTERVALS, f)
2161}
2162
2163/// [`quadrature_batch`] with the size floor supplied by the caller; see
2164/// [`integrate_ensemble_min`] for why the `_min` variants exist.
2165pub(crate) fn quadrature_batch_min<F>(
2166 intervals: &[QuadratureInterval],
2167 rule: QuadratureRule,
2168 backend: ComputeBackend,
2169 min_intervals: usize,
2170 f: F,
2171) -> QuadratureBatch
2172where
2173 F: Fn(usize, f64) -> f64 + Sync,
2174{
2175 // Gauss nodes are computed once per batch, not once per lane: the table is
2176 // a pure function of the order, so sharing it cannot change any lane's
2177 // value and it keeps the per-lane kernel free of setup cost.
2178 let nodes = match rule {
2179 QuadratureRule::GaussLegendre { order, .. } => gauss_legendre_nodes(order.points()),
2180 _ => Vec::new(),
2181 };
2182 let n = intervals.len();
2183 let solutions: Vec<QuadratureSolution> = match effective_backend(backend, n, min_intervals) {
2184 #[cfg(feature = "parallel")]
2185 ComputeBackend::CpuMulti => intervals
2186 .par_iter()
2187 .enumerate()
2188 .map(|(i, iv)| quadrature_one(i, *iv, rule, &nodes, &f))
2189 .collect(),
2190 _ => intervals
2191 .iter()
2192 .enumerate()
2193 .map(|(i, iv)| quadrature_one(i, *iv, rule, &nodes, &f))
2194 .collect(),
2195 };
2196 QuadratureBatch { solutions }
2197}
2198
2199/// Evaluate `N` independent definite integrals **adaptively**, on the chosen
2200/// backend.
2201///
2202/// Adaptive Simpson with local error control: each lane bisects wherever the
2203/// integrand resists a Simpson panel, and stops where it does not. Use it when
2204/// the integrand's difficulty is not known in advance, or differs between lanes,
2205/// or is concentrated in a small part of the interval — a peak in a resonance
2206/// integral, a boundary layer, a kink at a phase boundary.
2207///
2208/// # Why this path is CPU-only, and stays so
2209///
2210/// The subdivision pattern is decided by the integrand at run time, so
2211/// neighbouring lanes take different branches and perform different numbers of
2212/// evaluations. That is exactly the control-flow divergence SIMT hardware
2213/// handles worst — a GPU implementation would serialise the divergent branches
2214/// and need a per-lane work stack in device memory. Batching it across CPU cores
2215/// costs nothing and works; putting it on a GPU would be a large amount of code
2216/// for an unclear win. **It is deliberately CPU-only**, and this is the reason.
2217///
2218/// The CPU path is still fully hybrid: it takes a [`ComputeBackend`] and threads
2219/// across lanes exactly like every other kernel here.
2220///
2221/// # Arguments
2222///
2223/// - `intervals` — one [`QuadratureInterval`] per lane.
2224/// - `settings` — tolerances and the subdivision budget; see
2225/// [`AdaptiveSettings`].
2226/// - `backend` — requested backend; see [`quadrature_backend_for`]. The
2227/// [`QUADRATURE_MIN_INTERVALS`] floor is shared with [`quadrature_batch`] and
2228/// was measured on that path, so it is a conservative assumption here rather
2229/// than a measurement.
2230/// - `f` — the integrand, `f(i, x)`. Must be pure and deterministic; called from
2231/// multiple threads on the `CpuMulti` path.
2232///
2233/// # Returns
2234///
2235/// A [`QuadratureBatch`]. A lane that met its tolerance reports
2236/// [`QuadratureStatus::Evaluated`] and a meaningful
2237/// [`QuadratureSolution::error_estimate`]; a lane that exhausted its budget
2238/// reports [`QuadratureStatus::ToleranceNotMet`] and its best estimate, and is
2239/// excluded from [`QuadratureBatch::values`].
2240///
2241/// # Determinism
2242///
2243/// Bit-for-bit identical across backends and thread counts. The subdivision
2244/// order within a lane is a deterministic function of the integrand, and no
2245/// lane's arithmetic is split across threads.
2246///
2247/// # Verification
2248///
2249/// *Methodology.* Run against three integrands with closed forms, two of them
2250/// deliberately awkward for a uniform panel layout:
2251/// `integral of exp(-x) sin(x) from 0 to pi = (1 + exp(-pi)) / 2`;
2252/// `integral of sqrt(x) from 0 to 1 = 2/3`, which is bounded but has an
2253/// infinite derivative at the lower limit; and
2254/// `integral of 1 / (1 + 400 (x - 1/2)^2) from 0 to 1 = atan(10) / 10`, a peak
2255/// occupying about a twentieth of the interval. Tolerances `abs_tol = 1e-11`,
2256/// `rel_tol = 1e-10`, `max_subdivisions = 100 000`. Pass criteria: absolute
2257/// error below `1e-9`; every lane [`QuadratureStatus::Evaluated`]; and the
2258/// reported [`QuadratureSolution::error_estimate`] no more than 100x smaller
2259/// than the true error, since an estimate that badly understates the error
2260/// would be worse than none.
2261///
2262/// *Results, measured 2026-08-13 by `adaptive_matches_closed_forms` in
2263/// `parallel/tests.rs`, release build:*
2264///
2265/// | Integrand | Value | Error | Reported estimate | Evaluations |
2266/// |---|---|---|---|---|
2267/// | `exp(-x) sin(x)` on `[0, pi]` | 0.52160695913188759 | 1.443290e-15 | 2.034556e-11 | 469 |
2268/// | `sqrt(x)` on `[0, 1]` | 0.66666666666664931 | 1.731948e-14 | 2.619949e-11 | 1057 |
2269/// | narrow peak on `[0, 1]` | 0.14711276743037432 | 8.604228e-16 | 2.682116e-11 | 1225 |
2270///
2271/// *Interpretation.* All three land at or near the rounding floor, four to five
2272/// orders better than the requested `1e-11`, and the reported estimate is
2273/// conservative in every case — it overstates the true error by three to four
2274/// orders rather than understating it, which is the safe direction for an error
2275/// bound. The evaluation counts are the point of the adaptive path and the
2276/// source of the load imbalance work-stealing exists to absorb: 469 against
2277/// 1 225 for problems posed identically, a 2.6x spread decided entirely by the
2278/// integrand. `sqrt(x)` is the informative case — its infinite endpoint
2279/// derivative makes a uniform panel layout converge slowly, and the adaptive
2280/// path instead concentrates its subdivisions near `x = 0`.
2281///
2282/// # Limitations
2283///
2284/// **An integrand that is unbounded at an interval endpoint is not supported
2285/// here.** Adaptive Simpson evaluates `f(a)` and `f(b)` on its very first step,
2286/// so an integrable singularity sitting exactly on a limit — `ln(x)` or
2287/// `1/sqrt(x)` at `x = 0` — produces a non-finite first estimate and the lane
2288/// reports [`QuadratureStatus::NotFinite`]. This inverts the naive expectation
2289/// that the adaptive path is the more capable one: for an endpoint singularity
2290/// reach for [`quadrature_batch`] with
2291/// [`QuadratureRule::GaussLegendre`] instead, whose nodes are strictly interior
2292/// and never touch the limits. That is exactly the property `raffles` relies on
2293/// for its quantile-function moments. A singularity in the *interior* of the
2294/// interval is also not handled: it will exhaust the subdivision budget and
2295/// report [`QuadratureStatus::ToleranceNotMet`], which is at least honest.
2296///
2297/// # Example
2298///
2299/// ```rust
2300/// use outram_foam_basic_lib::compute::ComputeBackend;
2301/// use outram_foam_basic_lib::ode::parallel::{
2302/// adaptive_quadrature_batch, AdaptiveSettings, QuadratureInterval,
2303/// };
2304///
2305/// // Three lanes of `integral of exp(-a x) from 0 to 1` = (1 - exp(-a)) / a.
2306/// let a = [1.0_f64, 5.0, 20.0];
2307/// let intervals: Vec<QuadratureInterval> =
2308/// (0..3).map(|_| QuadratureInterval::new(0.0, 1.0)).collect();
2309///
2310/// let batch = adaptive_quadrature_batch(
2311/// &intervals,
2312/// AdaptiveSettings::default(),
2313/// ComputeBackend::CpuMulti,
2314/// |i, x| (-a[i] * x).exp(),
2315/// );
2316///
2317/// for (i, v) in batch.values().expect("all lanes meet tolerance").iter().enumerate() {
2318/// let exact = (1.0 - (-a[i]).exp()) / a[i];
2319/// assert!((v - exact).abs() < 1e-10, "lane {i}: {v} vs {exact}");
2320/// }
2321/// ```
2322#[must_use]
2323pub fn adaptive_quadrature_batch<F>(
2324 intervals: &[QuadratureInterval],
2325 settings: AdaptiveSettings,
2326 backend: ComputeBackend,
2327 f: F,
2328) -> QuadratureBatch
2329where
2330 F: Fn(usize, f64) -> f64 + Sync,
2331{
2332 adaptive_quadrature_batch_min(intervals, settings, backend, QUADRATURE_MIN_INTERVALS, f)
2333}
2334
2335/// [`adaptive_quadrature_batch`] with the size floor supplied by the caller; see
2336/// [`integrate_ensemble_min`] for why the `_min` variants exist.
2337pub(crate) fn adaptive_quadrature_batch_min<F>(
2338 intervals: &[QuadratureInterval],
2339 settings: AdaptiveSettings,
2340 backend: ComputeBackend,
2341 min_intervals: usize,
2342 f: F,
2343) -> QuadratureBatch
2344where
2345 F: Fn(usize, f64) -> f64 + Sync,
2346{
2347 let n = intervals.len();
2348 let solutions: Vec<QuadratureSolution> = match effective_backend(backend, n, min_intervals) {
2349 #[cfg(feature = "parallel")]
2350 ComputeBackend::CpuMulti => intervals
2351 .par_iter()
2352 .enumerate()
2353 // No `min_len` floor: adaptive evaluation counts are decided by the
2354 // integrand and vary between lanes (measured 469 to 1 225 on the
2355 // three verification integrands), so the splitter is left free to
2356 // steal down to a single lane.
2357 .map(|(i, iv)| adaptive_one(i, *iv, settings, &f))
2358 .collect(),
2359 _ => intervals
2360 .iter()
2361 .enumerate()
2362 .map(|(i, iv)| adaptive_one(i, *iv, settings, &f))
2363 .collect(),
2364 };
2365 QuadratureBatch { solutions }
2366}
2367
2368// ── Per-lane quadrature kernels — one implementation, both backends ──────────
2369
2370/// Sort a lane's limits and record the orientation sign.
2371///
2372/// Returns `Err(status)` when there is nothing to integrate: a non-finite limit,
2373/// or a zero-length interval (which is a *success* with value zero, handled by
2374/// the caller).
2375#[inline]
2376fn orient(iv: QuadratureInterval) -> Result<(f64, f64, f64), QuadratureStatus> {
2377 if !iv.a.is_finite() || !iv.b.is_finite() {
2378 return Err(QuadratureStatus::InvalidInterval);
2379 }
2380 if iv.a == iv.b {
2381 return Err(QuadratureStatus::Evaluated);
2382 }
2383 if iv.b > iv.a {
2384 Ok((iv.a, iv.b, 1.0))
2385 } else {
2386 Ok((iv.b, iv.a, -1.0))
2387 }
2388}
2389
2390/// Evaluate one lane with a fixed rule — the per-lane kernel both backends call.
2391#[inline]
2392fn quadrature_one<F>(
2393 i: usize,
2394 iv: QuadratureInterval,
2395 rule: QuadratureRule,
2396 nodes: &[(f64, f64)],
2397 f: &F,
2398) -> QuadratureSolution
2399where
2400 F: Fn(usize, f64) -> f64,
2401{
2402 let (lo, hi, sign) = match orient(iv) {
2403 Ok(t) => t,
2404 Err(QuadratureStatus::Evaluated) => {
2405 return QuadratureSolution {
2406 value: 0.0,
2407 error_estimate: f64::NAN,
2408 evaluations: 0,
2409 status: QuadratureStatus::Evaluated,
2410 }
2411 }
2412 Err(status) => return QuadratureSolution::failed(status, 0),
2413 };
2414
2415 let value = match rule {
2416 QuadratureRule::Trapezoid { panels } => {
2417 let p = panels_of(panels);
2418 let h = (hi - lo) / p as f64;
2419 let mut sum = 0.5 * (f(i, lo) + f(i, hi));
2420 for k in 1..p {
2421 sum += f(i, lo + k as f64 * h);
2422 }
2423 h * sum
2424 }
2425 QuadratureRule::Simpson { panels } => {
2426 let p = panels_of(panels);
2427 let sub = 2 * p;
2428 let h = (hi - lo) / sub as f64;
2429 let mut sum = f(i, lo) + f(i, hi);
2430 for k in 1..sub {
2431 let w = if k % 2 == 1 { 4.0 } else { 2.0 };
2432 sum += w * f(i, lo + k as f64 * h);
2433 }
2434 h / 3.0 * sum
2435 }
2436 QuadratureRule::GaussLegendre { panels, .. } => {
2437 let p = panels_of(panels);
2438 let width = (hi - lo) / p as f64;
2439 let half = 0.5 * width;
2440 let mut total = 0.0;
2441 for k in 0..p {
2442 let mid = lo + (k as f64 + 0.5) * width;
2443 let mut panel = 0.0;
2444 for &(node, weight) in nodes {
2445 panel += weight * f(i, mid + half * node);
2446 }
2447 total += half * panel;
2448 }
2449 total
2450 }
2451 };
2452
2453 let evaluations = rule.evaluations().min(u32::MAX as usize) as u32;
2454 if value.is_finite() {
2455 QuadratureSolution {
2456 value: sign * value,
2457 // A fixed rule has no error estimate to offer; `0.0` would be a lie.
2458 error_estimate: f64::NAN,
2459 evaluations,
2460 status: QuadratureStatus::Evaluated,
2461 }
2462 } else {
2463 QuadratureSolution::failed(QuadratureStatus::NotFinite, evaluations)
2464 }
2465}
2466
2467/// One pending sub-interval of the adaptive lane, held on an explicit stack.
2468///
2469/// An explicit stack rather than recursion so the depth is bounded by data the
2470/// kernel owns rather than by the thread's stack size — a `rayon` worker's stack
2471/// is not the caller's, and a pathological integrand must not be able to
2472/// overflow it.
2473#[derive(Debug, Clone, Copy)]
2474struct AdaptiveSegment {
2475 a: f64,
2476 b: f64,
2477 fa: f64,
2478 fm: f64,
2479 fb: f64,
2480 /// Simpson estimate over `[a, b]` using `fa`, `fm`, `fb`.
2481 whole: f64,
2482 /// Local tolerance allotted to this sub-interval.
2483 tol: f64,
2484 depth: u32,
2485}
2486
2487/// Evaluate one lane adaptively — the per-lane kernel both backends call.
2488///
2489/// Adaptive Simpson: compare the Simpson estimate over `[a, b]` with the sum of
2490/// the estimates over its two halves; accept when they agree to the local
2491/// tolerance, with the Richardson correction `(S_2 - S_1) / 15` applied, and
2492/// bisect otherwise with the tolerance split between the halves.
2493#[inline]
2494fn adaptive_one<F>(
2495 i: usize,
2496 iv: QuadratureInterval,
2497 settings: AdaptiveSettings,
2498 f: &F,
2499) -> QuadratureSolution
2500where
2501 F: Fn(usize, f64) -> f64,
2502{
2503 let (lo, hi, sign) = match orient(iv) {
2504 Ok(t) => t,
2505 Err(QuadratureStatus::Evaluated) => {
2506 return QuadratureSolution {
2507 value: 0.0,
2508 error_estimate: 0.0,
2509 evaluations: 0,
2510 status: QuadratureStatus::Evaluated,
2511 }
2512 }
2513 Err(status) => return QuadratureSolution::failed(status, 0),
2514 };
2515
2516 let mut evaluations: u32 = 0;
2517 let eval = |x: f64, evaluations: &mut u32| {
2518 *evaluations = evaluations.saturating_add(1);
2519 f(i, x)
2520 };
2521
2522 let fa = eval(lo, &mut evaluations);
2523 let fb = eval(hi, &mut evaluations);
2524 let m0 = 0.5 * (lo + hi);
2525 let fm = eval(m0, &mut evaluations);
2526 let whole = (hi - lo) / 6.0 * (fa + 4.0 * fm + fb);
2527
2528 // The tolerance is absolute plus relative-to-the-first-estimate. Using the
2529 // coarse whole-interval estimate as the scale is the standard choice: it is
2530 // available before any subdivision and cannot itself depend on the
2531 // subdivision path, which is what keeps the lane deterministic.
2532 let scale_tol = settings.abs_tol + settings.rel_tol * whole.abs();
2533
2534 let mut stack: Vec<AdaptiveSegment> = Vec::with_capacity(32);
2535 stack.push(AdaptiveSegment {
2536 a: lo,
2537 b: hi,
2538 fa,
2539 fm,
2540 fb,
2541 whole,
2542 tol: scale_tol,
2543 depth: 0,
2544 });
2545
2546 let mut total = 0.0_f64;
2547 let mut error = 0.0_f64;
2548 let mut splits: u32 = 0;
2549 let mut budget_exhausted = false;
2550
2551 while let Some(seg) = stack.pop() {
2552 let lm = 0.5 * (seg.a + seg.b);
2553 let ml = 0.5 * (seg.a + lm);
2554 let mr = 0.5 * (lm + seg.b);
2555 let fml = eval(ml, &mut evaluations);
2556 let fmr = eval(mr, &mut evaluations);
2557
2558 let h = seg.b - seg.a;
2559 let left = h / 12.0 * (seg.fa + 4.0 * fml + seg.fm);
2560 let right = h / 12.0 * (seg.fm + 4.0 * fmr + seg.fb);
2561 let refined = left + right;
2562 let delta = refined - seg.whole;
2563
2564 let out_of_budget = splits >= settings.max_subdivisions || seg.depth >= MAX_ADAPTIVE_DEPTH;
2565 if delta.abs() <= 15.0 * seg.tol || out_of_budget {
2566 if out_of_budget && delta.abs() > 15.0 * seg.tol {
2567 budget_exhausted = true;
2568 }
2569 total += refined + delta / 15.0;
2570 error += (delta / 15.0).abs();
2571 continue;
2572 }
2573
2574 splits = splits.saturating_add(1);
2575 let half_tol = 0.5 * seg.tol;
2576 stack.push(AdaptiveSegment {
2577 a: seg.a,
2578 b: lm,
2579 fa: seg.fa,
2580 fm: fml,
2581 fb: seg.fm,
2582 whole: left,
2583 tol: half_tol,
2584 depth: seg.depth + 1,
2585 });
2586 stack.push(AdaptiveSegment {
2587 a: lm,
2588 b: seg.b,
2589 fa: seg.fm,
2590 fm: fmr,
2591 fb: seg.fb,
2592 whole: right,
2593 tol: half_tol,
2594 depth: seg.depth + 1,
2595 });
2596 }
2597
2598 if !total.is_finite() {
2599 return QuadratureSolution::failed(QuadratureStatus::NotFinite, evaluations);
2600 }
2601
2602 QuadratureSolution {
2603 value: sign * total,
2604 error_estimate: error,
2605 evaluations,
2606 status: if budget_exhausted {
2607 QuadratureStatus::ToleranceNotMet
2608 } else {
2609 QuadratureStatus::Evaluated
2610 },
2611 }
2612}
2613
2614// ── Gauss-Legendre nodes ─────────────────────────────────────────────────────
2615
2616/// Nodes and weights of the `n`-point Gauss-Legendre rule on `[-1, 1]`, in
2617/// ascending node order.
2618///
2619/// **Computed, not transcribed.** The nodes are the roots of the Legendre
2620/// polynomial `P_n`, found by Newton iteration from the standard Chebyshev-like
2621/// initial guess `cos(pi (k - 1/4) / (n + 1/2))`, and the weights follow from
2622/// `w_k = 2 / ((1 - x_k^2) P_n'(x_k)^2)`. `P_n` and `P_n'` come from Bonnet's
2623/// recurrence. Computing them rather than copying a printed table means the
2624/// values are correct to the last bit `f64` can hold at any order, with no
2625/// transcription to get wrong and no literature dependency to record; the
2626/// exactness property of the rule (degree `2n - 1`) is then a complete
2627/// self-check, and is what `gauss_legendre_is_exact_to_its_degree` asserts.
2628///
2629/// The result is deterministic — the same nodes on every call and every thread —
2630/// so sharing one table across a batch's lanes cannot change any lane's value.
2631///
2632/// # Units
2633///
2634/// Dimensionless: nodes on `[-1, 1]`, weights summing to 2.
2635fn gauss_legendre_nodes(n: usize) -> Vec<(f64, f64)> {
2636 let mut out: Vec<(f64, f64)> = Vec::with_capacity(n);
2637 let nf = n as f64;
2638 for k in 1..=n {
2639 let mut x = (std::f64::consts::PI * (k as f64 - 0.25) / (nf + 0.5)).r_cos();
2640 // Newton on P_n. Converges quadratically from this guess; the cap is a
2641 // safety net, not the termination condition.
2642 for _ in 0..64 {
2643 let (p, dp) = legendre_p_and_dp(n, x);
2644 if dp == 0.0 {
2645 break;
2646 }
2647 let step = p / dp;
2648 x -= step;
2649 if step.abs() <= f64::EPSILON * x.abs().max(1.0) {
2650 break;
2651 }
2652 }
2653 let (_, dp) = legendre_p_and_dp(n, x);
2654 let w = 2.0 / ((1.0 - x * x) * dp * dp);
2655 out.push((x, w));
2656 }
2657 // The guess enumerates nodes from +1 downwards; ascending order is friendlier
2658 // to read and fixes the summation order in the kernel.
2659 out.reverse();
2660 out
2661}
2662
2663/// `P_n(x)` and `P_n'(x)` by Bonnet's recurrence.
2664///
2665/// `P_0 = 1`, `P_1 = x`, `j P_j = (2j - 1) x P_(j-1) - (j - 1) P_(j-2)`, and
2666/// `P_n'(x) = n (x P_n - P_(n-1)) / (x^2 - 1)`. Valid for `|x| < 1`, which is
2667/// where every Gauss node lies.
2668fn legendre_p_and_dp(n: usize, x: f64) -> (f64, f64) {
2669 if n == 0 {
2670 return (1.0, 0.0);
2671 }
2672 if n == 1 {
2673 return (x, 1.0);
2674 }
2675 let mut p_prev = 1.0_f64;
2676 let mut p = x;
2677 for j in 2..=n {
2678 let jf = j as f64;
2679 let p_next = ((2.0 * jf - 1.0) * x * p - (jf - 1.0) * p_prev) / jf;
2680 p_prev = p;
2681 p = p_next;
2682 }
2683 let dp = n as f64 * (x * p - p_prev) / (x * x - 1.0);
2684 (p, dp)
2685}