Skip to main content

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