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