Skip to main content

outram_foam_basic_lib/math/
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 scalar root finding on the hybrid execution backend — solve `N`
20//! independent `f(x) = 0` problems at once, serially or across CPU cores.
21//!
22//! # The shape of the problem
23//!
24//! A finite-volume solver almost never wants *one* root. It wants one root per
25//! cell: given a cell-wise target enthalpy `h_target[c]`, find the temperature
26//! `T[c]` satisfying `h(T[c]) - h_target[c] = 0`, for every cell in the mesh.
27//! Those `N` problems are completely independent of one another — no cell's
28//! iteration reads another cell's iterate — which is what makes the batch worth
29//! threading at all.
30//!
31//! This module therefore provides **batched** entry points only. A single scalar
32//! root solve should stay serial and inline in its caller: one root is a few
33//! microseconds of work and threading it is a straight loss.
34//!
35//! | Operation | Derivative needed? | Entry point |
36//! |---|---|---|
37//! | bisection or Brent over a bracket | no | [`solve_bracketed_batch`] |
38//! | safeguarded Newton over a bracket | yes | [`solve_newton_batch`] |
39//! | closed-form roots of `a x + b` | — | [`linear_roots_batch`] |
40//! | closed-form roots of `a x^2 + b x + c` | — | [`quadratic_roots_batch`] |
41//! | closed-form roots of `a x^3 + b x^2 + c x + d` | — | [`cubic_roots_batch`] |
42//!
43//! # Hybrid means dispatch, not two APIs
44//!
45//! Every entry point takes a [`ComputeBackend`] parameter. There is no
46//! `solve_batch_parallel()` sibling beside `solve_batch()`. With the `parallel`
47//! feature off, [`ComputeBackend::CpuMulti`] resolves down to
48//! [`ComputeBackend::Serial`] through [`ComputeBackend::resolve`] and the answer
49//! is unchanged — bit for bit, not merely close. There is no `Gpu` kernel here
50//! yet, so a `Gpu` request degrades to the best available CPU path.
51//!
52//! # Determinism — bitwise identical, and why that is stronger here
53//!
54//! **Every kernel in this module returns bit-for-bit identical output on
55//! [`ComputeBackend::Serial`] and [`ComputeBackend::CpuMulti`], at any thread
56//! count, on every run** — provided the caller's residual closure is itself a
57//! deterministic pure function of `(index, x)`.
58//!
59//! This is easier to achieve than it is for a reduction and the reason is worth
60//! stating, because it is the thing most people get wrong about parallel root
61//! finding. A parallel sum has to re-associate `a + b + c`, and floating-point
62//! addition is not associative, so its last bits depend on the split. A batch of
63//! root solves has **no cross-element arithmetic at all**: lane `i`'s answer is a
64//! pure function of lane `i`'s bracket and closure evaluations. Splitting the
65//! lanes across threads cannot perturb any of them. Both backends call the very
66//! same `#[inline]` per-lane kernel; only the identity of the calling thread
67//! differs.
68//!
69//! The one way a caller can break this is to supply a closure that is not a pure
70//! function — one that reads a random number generator, or accumulates into
71//! shared interior-mutable state, or depends on the calling thread. Such a
72//! closure makes the batch non-reproducible no matter what this module does. The
73//! `Sync` bound permits it; the documented contract forbids it.
74//!
75//! Verified by the `bitwise_*` tests in `parallel/tests.rs`, which compare
76//! serial against `rayon` pools of 1, 2, 4 and 8 workers on a batch deliberately
77//! built with wildly varying per-lane iteration counts.
78//!
79//! # Load imbalance — why there is no hand-rolled partition
80//!
81//! Iteration counts vary per lane, sometimes by an order of magnitude: a lane
82//! whose bracket happens to straddle the root tightly converges in three
83//! iterations, and its neighbour takes eighty. A static equal-split across `P`
84//! threads therefore ends up waiting on whichever chunk drew the hard lanes.
85//!
86//! The parallel paths here use `rayon`'s adaptive splitting (`par_iter_mut`,
87//! `into_par_iter`) with **no** `min_len` floor on the iterative solvers, so an
88//! idle worker steals down to individual lanes when the work is skewed. That is
89//! a deliberate choice, not an oversight: the whole reason to prefer
90//! work-stealing here is the per-lane cost variance. The closed-form polynomial
91//! kernels are the exception — every lane there costs the same handful of
92//! floating-point operations, so they set a granularity floor of
93//! [`POLY_BLOCK`] lanes to stop the splitter subdividing below a task that
94//! cannot pay for itself. That floor affects wall clock only; it cannot change a
95//! value, because each lane is independent.
96//!
97//! For scale, a 65 536-lane deliberately-imbalanced batch (half the lanes given
98//! a 2e-6-wide bracket, half given `[0, 1e6]`) under Brent, measured 2026-08-12
99//! on 4 logical cores by the `#[ignore]`d `root_batch_thread_scaling_benchmark`,
100//! best of 7 samples, with a second independent run alongside:
101//!
102//! | Worker threads | Time | Speed-up | (repeat) | Bitwise vs serial |
103//! |---|---|---|---|---|
104//! | *serial reference* | 19322.52 us | 1.00x | 1.00x | — |
105//! | 1 | 20510.52 us | 0.94x | 0.96x | identical |
106//! | 2 | 10123.65 us | 1.91x | 1.88x | identical |
107//! | 4 | 7075.89 us | 2.73x | 3.66x | identical |
108//! | 8 | 7314.01 us | 2.64x | 3.57x | identical |
109//!
110//! The "identical" column is the determinism claim above measured rather than
111//! argued, and it is asserted by the benchmark itself, not merely printed. The
112//! one-worker row costs about 6% against plain serial, which is the price of
113//! going through `rayon` at all. Unlike the memory-latency-bound sparse product
114//! in [`crate::ldu_matrix::parallel`], eight workers on four cores buy nothing
115//! further here — the expected signature of a compute-bound kernel that already
116//! saturates the cores it has. **This is one machine, one batch and two runs; it
117//! is not a scaling study**, and nothing here has been measured on Android
118//! hardware or on a many-core server.
119//!
120//! # Non-convergence is reported, never swallowed
121//!
122//! A batch of 10 000 problems in which 3 fail must say so. This module reports
123//! failure **per lane**, and makes the failure impossible to ignore by
124//! construction:
125//!
126//! - Every lane carries a [`RootStatus`] and is reachable through
127//!   [`RootBatch::solutions`].
128//! - [`RootSolution::root`] returns `Option<f64>` — `Some(x)` **only** when that
129//!   lane converged. There is no accessor that hands out a bare `f64` without
130//!   the caller having seen the status; the diagnostic iterate is behind the
131//!   deliberately-named [`RootSolution::last_iterate`].
132//! - [`RootBatch::roots`] is the all-or-nothing path: it returns
133//!   `Err(`[`RootBatchFailure`]`)` naming the failure count and the first
134//!   failing lane, rather than a plausible-looking `Vec<f64>`.
135//!
136//! **A non-converged lane is never clamped to a bracket endpoint and called a
137//! root.** A lane that ran out of iterations reports its best iterate with
138//! [`RootStatus::MaxIterations`]; a lane whose bracket does not straddle a root
139//! reports [`RootStatus::NotBracketed`] and a `NaN` iterate, because there is no
140//! honest number to return.
141//!
142//! # Why this module lives under `math/` and not `polynomial/`
143//!
144//! The bulk of it is a general scalar root finder over a caller-supplied
145//! residual — `h(T) - h_target`, a Peng-Robinson compressibility, a saturation
146//! curve. None of that is polynomial. `math/` is already the home of the
147//! crate's iterative inversions ([`crate::math::inv_inc_gamma`] is a
148//! Newton-refined inverse), so a general solver belongs beside them; a caller
149//! inverting an enthalpy would not think to look in a module called
150//! `polynomial`. The batched closed-form polynomial kernels are thin wrappers
151//! over [`crate::polynomial`]'s existing per-equation solvers and are kept here
152//! with the rest of the batching so that the crate has **one** batched
153//! root-finding vocabulary rather than two dialects in two modules.
154//!
155//! # Units
156//!
157//! Everything here is dimensionless `f64`, for the same reason
158//! [`crate::ldu_matrix::parallel`] and [`crate::krylov::vecops`] are: a generic
159//! root finder has no single physical dimension. The abscissa `x` of one lane
160//! may be a temperature in kelvin and of another a compressibility factor, and
161//! the residual's dimension is whatever the caller's function returns.
162//!
163//! `uom` typing is **not stripped** to get here — it is applied at the boundary,
164//! by the caller, exactly as the hybrid-backend epic requires: convert into the
165//! batch, convert back out. The doctest on [`solve_newton_batch`] shows that
166//! boundary explicitly, inverting a `uom`-typed enthalpy relation for a
167//! `ThermodynamicTemperature`.
168//!
169//! # Cargo features and portability
170//!
171//! The `rayon` paths sit behind the crate's `parallel` feature, which is **off
172//! by default**; with it off this module still compiles and every entry point
173//! still works. `rayon` is pure Rust with no system component, so everything
174//! here compiles and runs on `aarch64-linux-android` / Termux exactly as on
175//! desktop. Nothing in this module is target-gated.
176//!
177//! # Example
178//!
179//! ```rust
180//! use outram_foam_basic_lib::compute::ComputeBackend;
181//! use outram_foam_basic_lib::math::parallel::{
182//!     solve_bracketed_batch, RootMethod, RootProblem, RootSettings,
183//! };
184//!
185//! // 4 independent problems: x^2 = k for k = 1, 2, 3, 4, each bracketed on [0, 4].
186//! let targets = [1.0, 2.0, 3.0, 4.0];
187//! let problems: Vec<RootProblem> = (0..4).map(|_| RootProblem::new(0.0, 4.0)).collect();
188//!
189//! let batch = solve_bracketed_batch(
190//!     &problems,
191//!     RootMethod::Brent,
192//!     RootSettings::default(),
193//!     ComputeBackend::CpuMulti,
194//!     |i, x| x * x - targets[i],
195//! );
196//!
197//! let roots = batch.roots().expect("all four lanes converge");
198//! assert!((roots[1] - 2.0_f64.sqrt()).abs() < 1e-12);
199//!
200//! // Asking for multi-CPU gives a bit-for-bit identical answer, whether or not
201//! // the `parallel` feature is compiled in.
202//! let serial = solve_bracketed_batch(
203//!     &problems,
204//!     RootMethod::Brent,
205//!     RootSettings::default(),
206//!     ComputeBackend::Serial,
207//!     |i, x| x * x - targets[i],
208//! );
209//! assert_eq!(roots, serial.roots().unwrap());
210//! ```
211
212#[cfg(feature = "parallel")]
213use rayon::prelude::*;
214
215use crate::compute::ComputeBackend;
216use crate::polynomial::{CubicEqn, LinearEqn, QuadraticEqn, Roots};
217
218#[cfg(test)]
219mod tests;
220
221// ── Tuning constants ─────────────────────────────────────────────────────────
222
223/// Number of lanes in one task of the **closed-form polynomial** kernels
224/// ([`linear_roots_batch`], [`quadratic_roots_batch`], [`cubic_roots_batch`]).
225///
226/// `rayon` splits adaptively, so this is a *lower bound* on task granularity
227/// rather than a fixed task size: it stops the splitter subdividing below a
228/// block too small to pay for its own scheduling. A closed-form cubic solve is
229/// on the order of tens of nanoseconds, so splitting down to a single equation
230/// would spend more time in the scheduler than in Cardano's formula.
231///
232/// The iterative solvers deliberately do **not** use a floor — see the
233/// module-level "Load imbalance" section.
234///
235/// This constant affects wall clock only. It cannot change any value, because
236/// every lane is computed independently of every other.
237///
238/// # Units
239///
240/// A count of polynomial equations, dimensionless.
241pub const POLY_BLOCK: usize = 256;
242
243/// Problem count below which a [`ComputeBackend::CpuMulti`] request runs the
244/// **iterative** root-finding kernels ([`solve_bracketed_batch`],
245/// [`solve_newton_batch`]) on the calling thread instead.
246///
247/// # Measured crossover
248///
249/// *Methodology.* Measured 2026-08-12 on this workspace's development machine,
250/// `std::thread::available_parallelism()` = **4**, release build, `--features
251/// parallel`, `rayon`'s global pool, otherwise-idle machine. Batches of `n`
252/// independent problems `x^2 - k_i = 0` with `k_i` spread over `[1, 3)` so that
253/// per-lane iteration counts differ, bracket `[0, 4]`, [`RootMethod::Brent`],
254/// [`RootSettings::default`]. Best of 7 samples per point, reported as wall
255/// clock for one whole batch. Produced by the `#[ignore]`d
256/// `root_batch_crossover_benchmark` test in `parallel/tests.rs` and transcribed
257/// from its printed output. The `cheap` closure is the two-flop residual above;
258/// the `costly` closure adds a `ln`/`exp`/`sqrt` chain per evaluation, standing
259/// in for a JANAF enthalpy. The serial columns are from the first run; the
260/// `(repeat)` columns are the speed-ups from a second, independent run of the
261/// same benchmark, carried alongside rather than averaged away because the
262/// parallel column is far noisier than the serial one.
263///
264/// | Problems | cheap serial | cheap multi | speed-up | (repeat) | costly serial | costly multi | speed-up | (repeat) |
265/// |---|---|---|---|---|---|---|---|---|
266/// | 16 | 2.87 us | 34.77 us | 0.08x | 0.07x | 11.21 us | 42.19 us | 0.27x | 0.93x |
267/// | 32 | 6.67 us | 36.60 us | 0.18x | 0.71x | 19.32 us | 43.55 us | 0.44x | 1.35x |
268/// | 64 | 12.99 us | 45.16 us | 0.29x | 0.29x | 42.85 us | 62.99 us | 0.68x | 0.83x |
269/// | 128 | 25.96 us | 57.24 us | 0.45x | 0.59x | 84.88 us | 86.65 us | 0.98x | 1.26x |
270/// | 256 | 52.94 us | 42.79 us | 1.24x | 2.28x | 175.37 us | 126.70 us | 1.38x | 3.18x |
271/// | 512 | 108.80 us | 68.63 us | 1.59x | 2.91x | 356.78 us | 170.71 us | 2.09x | 2.04x |
272/// | 1 024 | 201.83 us | 127.52 us | 1.58x | 2.01x | 695.26 us | 284.70 us | 2.44x | 2.17x |
273/// | 4 096 | 805.49 us | 501.42 us | 1.61x | 3.63x | 2774.06 us | 992.73 us | 2.79x | 3.58x |
274/// | 16 384 | 3338.49 us | 1173.15 us | 2.85x | 2.71x | 11423.08 us | 3916.59 us | 2.92x | 2.83x |
275/// | 65 536 | 13730.89 us | 5071.05 us | 2.71x | 2.94x | 45778.24 us | 16260.96 us | 2.82x | 3.02x |
276///
277/// *Result.* **256** is the smallest size at which *neither* closure lost in
278/// *either* run — cheap 1.24x and 2.28x, costly 1.38x and 3.18x — and it is the
279/// value this constant takes. At 128 the cheap closure lost both runs (0.45x,
280/// 0.59x) while the costly one was a coin flip (0.98x, 1.26x), which is the
281/// expected shape: a more expensive residual crosses over earlier because there
282/// is more work per lane to amortise the same dispatch cost. The honest headline
283/// is therefore that **the crossover depends on the caller's residual**, and
284/// this single number is set for the cheapest residual a caller is likely to
285/// pass, so that it is safe for both.
286///
287/// *Interpretation.* This is 16x **lower** than the crate-wide placeholder
288/// [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`] (4 096) and 512x lower than
289/// [`crate::fields::parallel::FIELD_PARALLEL_CROSSOVER`] (131 072), and the
290/// reason is structural rather than incidental. Field algebra does one or two
291/// flops per element loaded and is memory-bandwidth bound, so extra cores buy
292/// almost nothing; a root solve does tens of iterations of arithmetic per lane
293/// against a handful of loaded bytes, so it is compute bound and threads scale
294/// on it. Keeping the crate-wide placeholder here would forfeit a measured
295/// 1.6x-2.4x on batches of 1 024.
296///
297/// # Relationship to [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`]
298///
299/// That constant documents itself as an unmeasured placeholder and invites
300/// per-kernel overrides. This is that override for the iterative root finders.
301/// It is now the fourth measured value in the crate, and the spread across the
302/// four (**256**, 4 096, 131 072, 262 144 — a factor of 1 024) is further
303/// evidence that no single crate-wide threshold can be right. Offered as input
304/// to bead `op-yvj.4.7`.
305///
306/// # Limitations
307///
308/// One machine, four logical cores, one closure family, idle. Not measured on
309/// Android/Termux hardware and not on a many-core server. The crossover moves
310/// with the caller's residual cost, so a caller that knows its own closure is
311/// expensive is better off calling the kernel with an explicit
312/// [`ComputeBackend`] than trusting a single number.
313///
314/// # Units
315///
316/// A count of independent root problems, dimensionless.
317pub const ROOT_BATCH_MIN_PROBLEMS: usize = 256;
318
319/// Equation count below which a [`ComputeBackend::CpuMulti`] request runs the
320/// **closed-form polynomial** kernels on the calling thread instead.
321///
322/// # Measured crossover
323///
324/// *Methodology.* Same machine, build and conditions as
325/// [`ROOT_BATCH_MIN_PROBLEMS`] (4 logical cores, release, `--features
326/// parallel`, idle, best of 7 samples). Batches of `n` cubics
327/// `(x - r1)(x - r2)(x - r3)` with pseudorandom real roots, solved by
328/// [`cubic_roots_batch`]; timed as wall clock for one whole batch. Produced by
329/// the `#[ignore]`d `poly_batch_crossover_benchmark` test in `parallel/tests.rs`
330/// and transcribed from its printed output. The `(repeat)` column is the
331/// speed-up from a second, independent run.
332///
333/// | Cubics | serial | CpuMulti | speed-up | (repeat) |
334/// |---|---|---|---|---|
335/// | 256 | 36.34 us | 36.43 us | 1.00x | 1.00x |
336/// | 512 | 71.92 us | 110.25 us | 0.65x | 0.83x |
337/// | 1 024 | 146.16 us | 96.48 us | 1.51x | 1.82x |
338/// | 2 048 | 296.38 us | 189.08 us | 1.57x | 1.37x |
339/// | 4 096 | 591.18 us | 170.46 us | 3.47x | 2.38x |
340/// | 16 384 | 2377.28 us | 832.47 us | 2.86x | 1.90x |
341/// | 65 536 | 10024.12 us | 3432.25 us | 2.92x | 2.76x |
342/// | 262 144 | 40089.01 us | 11204.91 us | 3.58x | 2.87x |
343///
344/// *Result.* Set to **1 024**, the smallest measured size at which the parallel
345/// path won in both runs (1.51x, 1.82x). At 512 it lost both (0.65x, 0.83x).
346///
347/// *Interpretation.* Note the 256-cubic row, where the two paths are *exactly*
348/// break-even in both runs: [`POLY_BLOCK`] is 256, so there is a single task and
349/// `rayon` never splits — the same artefact the `ldu_matrix` and `fields`
350/// crossover tables show at their own block sizes, and a useful confirmation
351/// that the granularity floor is doing what it claims.
352///
353/// This floor is 4x the iterative one, but **do not read that as a strong
354/// structural difference**: the two kernels' per-lane costs are in fact
355/// comparable. Dividing the largest serial timings by their lane counts gives
356/// about **153 ns** per closed-form cubic against about **209 ns** per cheap
357/// iterative lane. Both break even in the same shallow region, and the 4x gap
358/// between the two constants reflects a conservative reading of a noisy
359/// measurement — taking the smallest size that won in *both* runs — more than it
360/// reflects physics. Both are still far below the crate-wide placeholder,
361/// because both kernels are compute-dense next to a field add.
362///
363/// # Limitations
364///
365/// As [`ROOT_BATCH_MIN_PROBLEMS`]: one machine, four cores, idle, not measured
366/// on Android. Measured with [`cubic_roots_batch`] only —
367/// [`quadratic_roots_batch`] and [`linear_roots_batch`] are cheaper per lane and
368/// so cross over *later*, and inherit this floor as an assumption rather than a
369/// measurement.
370///
371/// # Units
372///
373/// A count of polynomial equations, dimensionless.
374pub const POLY_ROOTS_MIN_EQUATIONS: usize = 1_024;
375
376// ── Backend dispatch ─────────────────────────────────────────────────────────
377
378/// Resolve a requested backend to the one this module will actually run, given
379/// how much work there is.
380///
381/// Three reductions, in order: [`ComputeBackend::resolve`] degrades anything
382/// whose feature is off; `Gpu` degrades again because this module has no GPU
383/// kernel yet; and `CpuMulti` degrades to `Serial` below `min_work_items`. The
384/// result is only ever `Serial` or `CpuMulti`, and none of the degradations can
385/// change a returned value.
386fn effective_backend(
387    requested: ComputeBackend,
388    work_items: usize,
389    min_work_items: usize,
390) -> ComputeBackend {
391    let cpu = match requested.resolve() {
392        ComputeBackend::Gpu => ComputeBackend::CpuMulti.resolve(),
393        other => other,
394    };
395    match cpu {
396        ComputeBackend::CpuMulti if work_items >= min_work_items => ComputeBackend::CpuMulti,
397        _ => ComputeBackend::Serial,
398    }
399}
400
401/// The [`ComputeBackend`] the iterative solvers would actually use for `n`
402/// problems if asked for `requested` — without running anything.
403///
404/// Applies exactly the same reduction the kernels do (feature availability, no
405/// GPU kernel here, and the [`ROOT_BATCH_MIN_PROBLEMS`] size floor), so what it
406/// reports is what would run. Useful for logging and for benchmark harnesses.
407///
408/// # Arguments
409///
410/// - `requested` — the backend a caller would pass to [`solve_bracketed_batch`]
411///   or [`solve_newton_batch`].
412/// - `n` — the number of independent problems in the batch, dimensionless.
413///
414/// # Returns
415///
416/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`]; never
417/// [`ComputeBackend::Gpu`], because no GPU kernel exists here yet.
418///
419/// # Example
420///
421/// ```rust
422/// use outram_foam_basic_lib::compute::ComputeBackend;
423/// use outram_foam_basic_lib::math::parallel::{root_batch_backend_for, ROOT_BATCH_MIN_PROBLEMS};
424///
425/// // Too small to thread, whatever was asked for.
426/// assert_eq!(root_batch_backend_for(ComputeBackend::CpuMulti, 8), ComputeBackend::Serial);
427///
428/// // Big enough; the answer now depends only on whether `parallel` is compiled in.
429/// let picked = root_batch_backend_for(ComputeBackend::CpuMulti, ROOT_BATCH_MIN_PROBLEMS);
430/// assert!(picked.is_available());
431/// ```
432#[must_use]
433pub fn root_batch_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
434    effective_backend(requested, n, ROOT_BATCH_MIN_PROBLEMS)
435}
436
437/// The [`ComputeBackend`] the closed-form polynomial kernels would actually use
438/// for `n` equations if asked for `requested` — without running anything.
439///
440/// The polynomial counterpart of [`root_batch_backend_for`], differing only in
441/// using the [`POLY_ROOTS_MIN_EQUATIONS`] size floor.
442///
443/// # Arguments
444///
445/// - `requested` — the backend a caller would pass to [`cubic_roots_batch`] and
446///   friends.
447/// - `n` — the number of equations in the batch, dimensionless.
448///
449/// # Returns
450///
451/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`].
452///
453/// # Example
454///
455/// ```rust
456/// use outram_foam_basic_lib::compute::ComputeBackend;
457/// use outram_foam_basic_lib::math::parallel::poly_roots_backend_for;
458///
459/// assert_eq!(poly_roots_backend_for(ComputeBackend::CpuMulti, 64), ComputeBackend::Serial);
460/// assert_eq!(poly_roots_backend_for(ComputeBackend::Serial, 1 << 20), ComputeBackend::Serial);
461/// ```
462#[must_use]
463pub fn poly_roots_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
464    effective_backend(requested, n, POLY_ROOTS_MIN_EQUATIONS)
465}
466
467// ── Problem, settings, method ────────────────────────────────────────────────
468
469/// Which derivative-free bracketed method [`solve_bracketed_batch`] should run.
470///
471/// Both methods keep a bracket `[a, b]` across which the residual changes sign
472/// and shrink it until it meets the tolerance, so both are guaranteed to
473/// converge on a continuous residual that is genuinely bracketed. They differ
474/// only in how fast.
475///
476/// # Units
477///
478/// Dimensionless — a mode selector, not a quantity.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
480pub enum RootMethod {
481    /// Repeated bisection of the bracket.
482    ///
483    /// One residual evaluation per iteration, and the bracket width halves
484    /// every time, so reaching a relative tolerance of `1e-12` from an
485    /// order-unity bracket costs about 40 evaluations regardless of the
486    /// function. Slow but utterly unshakeable: it cannot be defeated by a kink,
487    /// a plateau, or a badly conditioned derivative.
488    Bisection,
489    /// Brent's method — inverse quadratic interpolation and the secant rule,
490    /// falling back to bisection whenever the interpolated step misbehaves.
491    ///
492    /// The default, and the right choice unless you have a specific reason to
493    /// want bisection's fixed cost. Superlinear on a smooth residual: the same
494    /// `1e-12` tolerance typically costs 6-12 evaluations instead of 40, while
495    /// retaining bisection's guarantee because every step that would leave the
496    /// bracket or shrink it too slowly is replaced by a bisection step.
497    ///
498    /// Implemented from the published description of the algorithm (R. P.
499    /// Brent, *Algorithms for Minimization without Derivatives*, Prentice-Hall
500    /// 1973, chapter 4), not transcribed from any existing implementation.
501    #[default]
502    Brent,
503}
504
505/// One bracketed root problem: an interval believed to contain a root, plus an
506/// optional starting iterate for the Newton solver.
507///
508/// # Fields and valid ranges
509///
510/// - `lo`, `hi` — the bracket. Both must be finite. They may be given in either
511///   order; the solvers do not require `lo < hi`. The residual must have
512///   opposite signs at the two ends (or be exactly zero at one of them),
513///   otherwise the lane reports [`RootStatus::NotBracketed`] rather than
514///   guessing.
515/// - `guess` — the initial iterate for [`solve_newton_batch`]. Ignored by
516///   [`solve_bracketed_batch`], which is driven entirely by the bracket. A
517///   non-finite guess, or one outside `[lo, hi]`, is replaced by the bracket
518///   midpoint rather than rejected — a bad guess is a performance problem, not
519///   a correctness one, because the Newton solver is bracket-safeguarded.
520///
521/// # Units
522///
523/// Dimensionless `f64` in whatever units the caller's abscissa carries — kelvin
524/// for a temperature inversion, dimensionless for a compressibility factor. See
525/// the module-level "Units" section.
526///
527/// # Example
528///
529/// ```rust
530/// use outram_foam_basic_lib::math::parallel::RootProblem;
531///
532/// // Bracket only; the Newton guess defaults to the midpoint.
533/// let p = RootProblem::new(300.0, 3000.0);
534/// assert_eq!(p.guess, 1650.0);
535///
536/// // Or seed Newton from the previous timestep's answer.
537/// let q = RootProblem::with_guess(300.0, 3000.0, 812.5);
538/// assert_eq!(q.guess, 812.5);
539/// ```
540#[derive(Debug, Clone, Copy, PartialEq)]
541pub struct RootProblem {
542    /// One end of the bracket. Finite; need not be less than `hi`.
543    pub lo: f64,
544    /// The other end of the bracket. Finite; need not be greater than `lo`.
545    pub hi: f64,
546    /// Starting iterate for [`solve_newton_batch`]; ignored by
547    /// [`solve_bracketed_batch`].
548    pub guess: f64,
549}
550
551impl RootProblem {
552    /// A problem bracketed on `[lo, hi]`, with the Newton guess set to the
553    /// bracket midpoint.
554    ///
555    /// # Arguments
556    ///
557    /// - `lo`, `hi` — the bracket ends, finite, in either order.
558    #[must_use]
559    pub fn new(lo: f64, hi: f64) -> Self {
560        Self {
561            lo,
562            hi,
563            guess: lo + 0.5 * (hi - lo),
564        }
565    }
566
567    /// A problem bracketed on `[lo, hi]` with an explicit Newton starting
568    /// iterate.
569    ///
570    /// Seeding from the previous timestep's converged value is the usual reason
571    /// to reach for this: it typically halves the iteration count of a per-cell
572    /// thermodynamic inversion.
573    ///
574    /// # Arguments
575    ///
576    /// - `lo`, `hi` — the bracket ends, finite, in either order.
577    /// - `guess` — starting iterate. Silently replaced by the midpoint if it is
578    ///   non-finite or outside the bracket.
579    #[must_use]
580    pub fn with_guess(lo: f64, hi: f64, guess: f64) -> Self {
581        Self { lo, hi, guess }
582    }
583}
584
585/// Convergence tolerances and the iteration cap, shared by both iterative
586/// solvers.
587///
588/// A lane is converged when **either** the bracket (bisection, Brent) or the
589/// step (Newton) has shrunk to `x_tol_abs + x_tol_rel * |x|`, **or** the
590/// residual magnitude has fallen to `f_tol`. An exactly-zero residual always
591/// converges regardless of tolerances.
592///
593/// # Fields and valid ranges
594///
595/// - `x_tol_abs` — absolute abscissa tolerance, in the abscissa's own units.
596///   Must be `>= 0`. Guards the case `x ≈ 0`, where a purely relative tolerance
597///   can never be met.
598/// - `x_tol_rel` — relative abscissa tolerance, dimensionless, `>= 0`.
599/// - `f_tol` — residual tolerance, in the residual's own units, `>= 0`. Default
600///   `0.0`, i.e. **disabled**: with no knowledge of the residual's scale, a
601///   nonzero default would be a guess. Set it when you know what "small enough"
602///   means for your function; leaving it at zero simply means the abscissa
603///   tolerance decides.
604/// - `max_iterations` — hard cap. On reaching it a lane reports
605///   [`RootStatus::MaxIterations`] and its best iterate. Must be `>= 1`.
606///
607/// A negative tolerance is not rejected; it simply can never be met, and the
608/// lane will report [`RootStatus::MaxIterations`]. That is a truthful outcome
609/// rather than a silent success.
610///
611/// # Defaults
612///
613/// `x_tol_abs = 1e-12`, `x_tol_rel = 1e-12`, `f_tol = 0.0`,
614/// `max_iterations = 100`. One hundred iterations is comfortable for Brent
615/// (typically 6-12) and adequate for bisection over any bracket narrower than
616/// about `2^100` tolerances.
617///
618/// # Example
619///
620/// ```rust
621/// use outram_foam_basic_lib::math::parallel::RootSettings;
622///
623/// // Struct-update syntax keeps the defaults you did not mean to change.
624/// let s = RootSettings {
625///     max_iterations: 200,
626///     ..RootSettings::default()
627/// };
628/// assert_eq!(s.x_tol_rel, 1e-12);
629/// assert_eq!(s.max_iterations, 200);
630/// ```
631#[derive(Debug, Clone, Copy, PartialEq)]
632pub struct RootSettings {
633    /// Absolute abscissa tolerance, in the abscissa's units. `>= 0`.
634    pub x_tol_abs: f64,
635    /// Relative abscissa tolerance, dimensionless. `>= 0`.
636    pub x_tol_rel: f64,
637    /// Residual tolerance, in the residual's units. `0.0` disables it.
638    pub f_tol: f64,
639    /// Maximum iterations per lane before reporting
640    /// [`RootStatus::MaxIterations`]. `>= 1`.
641    pub max_iterations: u32,
642}
643
644impl Default for RootSettings {
645    fn default() -> Self {
646        Self {
647            x_tol_abs: 1e-12,
648            x_tol_rel: 1e-12,
649            f_tol: 0.0,
650            max_iterations: 100,
651        }
652    }
653}
654
655impl RootSettings {
656    /// The abscissa tolerance at `x`: `x_tol_abs + x_tol_rel * |x|`.
657    #[inline]
658    fn x_tol(&self, x: f64) -> f64 {
659        self.x_tol_abs + self.x_tol_rel * x.abs()
660    }
661
662    /// Whether a residual of this magnitude counts as converged.
663    ///
664    /// An exactly-zero residual always does, whatever `f_tol` is.
665    #[inline]
666    fn residual_small_enough(&self, f: f64) -> bool {
667        f == 0.0 || f.abs() <= self.f_tol
668    }
669}
670
671// ── Per-lane outcome ─────────────────────────────────────────────────────────
672
673/// How one lane of a batch ended.
674///
675/// Only [`Converged`](Self::Converged) means the lane produced a root. Every
676/// other variant is a failure that a caller must handle; see the module-level
677/// "Non-convergence is reported, never swallowed" section for the accessors
678/// that make it hard to skip.
679///
680/// | Variant | `last_iterate` | Meaning |
681/// |---|---|---|
682/// | [`Converged`](Self::Converged) | the root | a tolerance was met |
683/// | [`MaxIterations`](Self::MaxIterations) | best iterate so far — **not** a root | ran out of iterations |
684/// | [`NotBracketed`](Self::NotBracketed) | `NaN` | residual has the same sign at both bracket ends |
685/// | [`InvalidBracket`](Self::InvalidBracket) | `NaN` | a bracket end is not finite |
686/// | [`NotFinite`](Self::NotFinite) | the abscissa where it happened | the residual (or derivative) evaluated to `NaN`/infinity |
687///
688/// # Units
689///
690/// Dimensionless — a status tag.
691#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
692pub enum RootStatus {
693    /// A root was found to the requested tolerance.
694    Converged,
695    /// `max_iterations` was reached with no tolerance met. The reported iterate
696    /// is the best one seen, and is **not** claimed to be a root.
697    MaxIterations,
698    /// The residual has the same (nonzero) sign at both ends of the bracket, so
699    /// no root of a continuous residual is guaranteed to lie inside it. The
700    /// solver refuses to guess and reports this instead of returning an
701    /// endpoint.
702    NotBracketed,
703    /// A bracket end was infinite or `NaN`, so there is no interval to search.
704    InvalidBracket,
705    /// The residual — or, for Newton, the value half of the residual/derivative
706    /// pair — evaluated to a non-finite number. Iteration stops immediately,
707    /// because continuing would propagate the `NaN` into a plausible-looking
708    /// answer.
709    NotFinite,
710}
711
712impl RootStatus {
713    /// Whether this status means the lane produced a usable root.
714    ///
715    /// # Example
716    ///
717    /// ```rust
718    /// use outram_foam_basic_lib::math::parallel::RootStatus;
719    ///
720    /// assert!(RootStatus::Converged.is_converged());
721    /// assert!(!RootStatus::MaxIterations.is_converged());
722    /// ```
723    #[must_use]
724    pub fn is_converged(self) -> bool {
725        matches!(self, Self::Converged)
726    }
727
728    /// A short human-readable label, for log lines and failure reports.
729    #[must_use]
730    pub fn label(self) -> &'static str {
731        match self {
732            Self::Converged => "converged",
733            Self::MaxIterations => "max-iterations",
734            Self::NotBracketed => "not-bracketed",
735            Self::InvalidBracket => "invalid-bracket",
736            Self::NotFinite => "not-finite",
737        }
738    }
739}
740
741/// The outcome of a single lane: its status, its iterate, its residual and the
742/// work it took.
743///
744/// The fields are private on purpose. The only way to get a number that is
745/// claimed to be a root is [`Self::root`], which returns `Option<f64>` and hands
746/// back `Some` only for a converged lane. The raw iterate is available from
747/// [`Self::last_iterate`], whose name is chosen so that using it as a root is a
748/// visible decision in the calling code rather than an accident.
749///
750/// `Copy`, so it can be read out of a [`RootBatch`] without cloning.
751///
752/// # Units
753///
754/// [`Self::root`] and [`Self::last_iterate`] are in the abscissa's units;
755/// [`Self::residual`] is in the residual's units; [`Self::iterations`] and
756/// [`Self::bracket_width`] are a dimensionless count and an abscissa-unit width
757/// respectively.
758#[derive(Debug, Clone, Copy, PartialEq)]
759pub struct RootSolution {
760    x: f64,
761    f_x: f64,
762    bracket_width: f64,
763    iterations: u32,
764    status: RootStatus,
765}
766
767impl RootSolution {
768    /// The root, if this lane converged.
769    ///
770    /// `None` for every failure status. This is the accessor to reach for; the
771    /// `Option` is the point.
772    ///
773    /// # Returns
774    ///
775    /// `Some(x)` in the abscissa's units when [`Self::status`] is
776    /// [`RootStatus::Converged`], otherwise `None`.
777    #[must_use]
778    pub fn root(&self) -> Option<f64> {
779        if self.status.is_converged() {
780            Some(self.x)
781        } else {
782            None
783        }
784    }
785
786    /// The last iterate the solver held, converged or not.
787    ///
788    /// **A root only when [`Self::status`] is [`RootStatus::Converged`].** For
789    /// [`RootStatus::MaxIterations`] this is the best estimate available and is
790    /// offered for diagnosis — for deciding whether the lane was close or wildly
791    /// off — not as an answer. For [`RootStatus::NotBracketed`] and
792    /// [`RootStatus::InvalidBracket`] it is `NaN`, because there is no honest
793    /// number to report and returning a bracket endpoint would be a lie.
794    #[must_use]
795    pub fn last_iterate(&self) -> f64 {
796        self.x
797    }
798
799    /// The residual at [`Self::last_iterate`], in the residual's units.
800    ///
801    /// `NaN` when no meaningful evaluation was reached.
802    #[must_use]
803    pub fn residual(&self) -> f64 {
804        self.f_x
805    }
806
807    /// Width of the final bracket, in the abscissa's units.
808    ///
809    /// For Newton this is the width of the safeguarding bracket, not the final
810    /// step. `NaN` when the lane never established a bracket.
811    #[must_use]
812    pub fn bracket_width(&self) -> f64 {
813        self.bracket_width
814    }
815
816    /// Iterations performed on this lane, dimensionless.
817    ///
818    /// One iteration costs one residual evaluation, on top of the two bracket
819    /// evaluations every lane pays up front (three for Newton, which also
820    /// evaluates at its starting iterate). Useful for spotting that one lane in
821    /// ten thousand which is dragging the whole batch.
822    #[must_use]
823    pub fn iterations(&self) -> u32 {
824        self.iterations
825    }
826
827    /// How this lane ended.
828    #[must_use]
829    pub fn status(&self) -> RootStatus {
830        self.status
831    }
832
833    /// Whether this lane produced a root.
834    #[must_use]
835    pub fn converged(&self) -> bool {
836        self.status.is_converged()
837    }
838
839    /// A converged lane.
840    #[inline]
841    fn converged_at(x: f64, f_x: f64, bracket_width: f64, iterations: u32) -> Self {
842        Self {
843            x,
844            f_x,
845            bracket_width,
846            iterations,
847            status: RootStatus::Converged,
848        }
849    }
850
851    /// A failed lane, carrying whatever diagnostic information exists.
852    #[inline]
853    fn failed(status: RootStatus, x: f64, f_x: f64, bracket_width: f64, iterations: u32) -> Self {
854        Self {
855            x,
856            f_x,
857            bracket_width,
858            iterations,
859            status,
860        }
861    }
862}
863
864/// A batch of `N` lane outcomes, in the same order as the problems handed in.
865///
866/// Lane `i` of the result corresponds to `problems[i]`, always — the parallel
867/// path preserves order, so no index bookkeeping is needed.
868///
869/// # Getting numbers out
870///
871/// - [`Self::roots`] — all-or-nothing. `Ok(Vec<f64>)` only when every lane
872///   converged; otherwise `Err(`[`RootBatchFailure`]`)`. Use this when a partial
873///   answer is useless, which for a per-cell thermodynamic inversion it usually
874///   is.
875/// - [`Self::solutions`] — per-lane, when the caller wants to handle failures
876///   individually (fall back to a wider bracket, flag the cell, sub-cycle).
877///
878/// # Units
879///
880/// See [`RootSolution`].
881#[derive(Debug, Clone, PartialEq)]
882pub struct RootBatch {
883    solutions: Vec<RootSolution>,
884}
885
886impl RootBatch {
887    /// Every lane's outcome, in problem order.
888    #[must_use]
889    pub fn solutions(&self) -> &[RootSolution] {
890        &self.solutions
891    }
892
893    /// Consume the batch and take the outcomes.
894    #[must_use]
895    pub fn into_solutions(self) -> Vec<RootSolution> {
896        self.solutions
897    }
898
899    /// Number of lanes, dimensionless.
900    #[must_use]
901    pub fn len(&self) -> usize {
902        self.solutions.len()
903    }
904
905    /// Whether the batch has no lanes.
906    #[must_use]
907    pub fn is_empty(&self) -> bool {
908        self.solutions.is_empty()
909    }
910
911    /// Lane `i`'s outcome, or `None` if `i` is out of range.
912    #[must_use]
913    pub fn get(&self, i: usize) -> Option<RootSolution> {
914        self.solutions.get(i).copied()
915    }
916
917    /// Whether every lane converged. Vacuously `true` for an empty batch.
918    #[must_use]
919    pub fn all_converged(&self) -> bool {
920        self.solutions.iter().all(RootSolution::converged)
921    }
922
923    /// How many lanes failed to converge, dimensionless.
924    #[must_use]
925    pub fn failure_count(&self) -> usize {
926        self.solutions.iter().filter(|s| !s.converged()).count()
927    }
928
929    /// The first failing lane and its outcome, if any.
930    ///
931    /// The natural thing to print when a batch fails: it names the cell to look
932    /// at rather than just the count.
933    #[must_use]
934    pub fn first_failure(&self) -> Option<(usize, RootSolution)> {
935        self.solutions
936            .iter()
937            .enumerate()
938            .find(|(_, s)| !s.converged())
939            .map(|(i, s)| (i, *s))
940    }
941
942    /// Every failing lane, as `(index, outcome)` pairs.
943    ///
944    /// Allocates, so prefer [`Self::first_failure`] or [`Self::failure_count`]
945    /// on a hot path.
946    #[must_use]
947    pub fn failures(&self) -> Vec<(usize, RootSolution)> {
948        self.solutions
949            .iter()
950            .enumerate()
951            .filter(|(_, s)| !s.converged())
952            .map(|(i, s)| (i, *s))
953            .collect()
954    }
955
956    /// All roots, or an error naming the failures — the all-or-nothing path.
957    ///
958    /// # Returns
959    ///
960    /// `Ok(v)` with `v[i]` the root of problem `i`, in the abscissa's units,
961    /// when every lane converged. Otherwise `Err(`[`RootBatchFailure`]`)`
962    /// carrying the failure count and the first failing lane. **No `Vec` of
963    /// plausible-looking numbers is ever returned for a batch that contained a
964    /// failure.**
965    ///
966    /// An empty batch returns `Ok(vec![])`.
967    ///
968    /// # Errors
969    ///
970    /// [`RootBatchFailure`] when one or more lanes did not converge.
971    ///
972    /// # Example
973    ///
974    /// ```rust
975    /// use outram_foam_basic_lib::compute::ComputeBackend;
976    /// use outram_foam_basic_lib::math::parallel::{
977    ///     solve_bracketed_batch, RootMethod, RootProblem, RootSettings, RootStatus,
978    /// };
979    ///
980    /// // Lane 1's bracket does not straddle a root of x^2 - 2.
981    /// let problems = [RootProblem::new(0.0, 4.0), RootProblem::new(3.0, 4.0)];
982    /// let batch = solve_bracketed_batch(
983    ///     &problems,
984    ///     RootMethod::Brent,
985    ///     RootSettings::default(),
986    ///     ComputeBackend::Serial,
987    ///     |_, x| x * x - 2.0,
988    /// );
989    ///
990    /// let err = batch.roots().expect_err("lane 1 is not bracketed");
991    /// assert_eq!(err.failure_count, 1);
992    /// assert_eq!(err.first_index, 1);
993    /// assert_eq!(err.first_status, RootStatus::NotBracketed);
994    ///
995    /// // The converged lane is still individually readable.
996    /// assert!(batch.solutions()[0].root().is_some());
997    /// assert!(batch.solutions()[1].root().is_none());
998    /// ```
999    pub fn roots(&self) -> Result<Vec<f64>, RootBatchFailure> {
1000        if let Some((i, s)) = self.first_failure() {
1001            return Err(RootBatchFailure {
1002                total: self.solutions.len(),
1003                failure_count: self.failure_count(),
1004                first_index: i,
1005                first_status: s.status(),
1006                first_iterations: s.iterations(),
1007            });
1008        }
1009        Ok(self.solutions.iter().map(|s| s.x).collect())
1010    }
1011}
1012
1013/// One or more lanes of a [`RootBatch`] did not converge.
1014///
1015/// Returned by [`RootBatch::roots`]. It names both the scale of the problem
1016/// (how many of how many) and a specific lane to look at, because "3 of 10 000
1017/// cells failed" is only actionable once you know *which* cell.
1018///
1019/// # Units
1020///
1021/// All counts and indices are dimensionless.
1022#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1023#[error(
1024    "{failure_count} of {total} root problems did not converge; \
1025     first failure at lane {first_index} with status {first_status:?} \
1026     after {first_iterations} iterations"
1027)]
1028pub struct RootBatchFailure {
1029    /// Number of lanes in the batch.
1030    pub total: usize,
1031    /// Number of lanes that did not converge.
1032    pub failure_count: usize,
1033    /// Index of the first non-converged lane.
1034    pub first_index: usize,
1035    /// Why that lane failed.
1036    pub first_status: RootStatus,
1037    /// Iterations that lane performed before giving up.
1038    pub first_iterations: u32,
1039}
1040
1041// ── Iterative solvers — the public batch entry points ────────────────────────
1042
1043/// Solve `N` independent bracketed root problems without a derivative, on the
1044/// chosen backend.
1045///
1046/// This is the entry point for the common case: a residual you can evaluate but
1047/// whose derivative you would rather not write, bracketed per lane. Both
1048/// [`RootMethod`]s are globally convergent on a continuous, genuinely bracketed
1049/// residual.
1050///
1051/// # Arguments
1052///
1053/// - `problems` — one [`RootProblem`] per lane. `problems[i]`'s bracket is used
1054///   with `f(i, ·)`. The `guess` field is ignored.
1055/// - `method` — [`RootMethod::Brent`] unless you specifically want bisection's
1056///   fixed, function-independent cost.
1057/// - `settings` — tolerances and iteration cap; see [`RootSettings`].
1058/// - `backend` — requested execution backend. What actually runs is
1059///   [`root_batch_backend_for`] applied to it: an unavailable backend degrades,
1060///   `Gpu` degrades (no GPU kernel here yet), and a batch below
1061///   [`ROOT_BATCH_MIN_PROBLEMS`] runs serially. None of those changes the
1062///   answer.
1063/// - `f` — the residual. `f(i, x)` must return the value of lane `i`'s function
1064///   at abscissa `x`. It **must be a pure deterministic function of its
1065///   arguments** — see the module-level "Determinism" section for what breaks if
1066///   it is not. It is called from multiple threads on the `CpuMulti` path,
1067///   hence the `Sync` bound; the bound is present in both feature builds so
1068///   that enabling `parallel` never changes a public signature.
1069///
1070/// # Returns
1071///
1072/// A [`RootBatch`] with one [`RootSolution`] per problem, in problem order. An
1073/// empty `problems` slice returns an empty batch and calls `f` zero times.
1074///
1075/// # Determinism
1076///
1077/// Bit-for-bit identical on [`ComputeBackend::Serial`] and
1078/// [`ComputeBackend::CpuMulti`], at any thread count, for a pure `f`. Both
1079/// backends run the same per-lane kernel and no arithmetic crosses lanes.
1080///
1081/// # Non-convergence
1082///
1083/// Per lane, never swallowed. A lane whose bracket does not straddle a root
1084/// reports [`RootStatus::NotBracketed`] and a `NaN` iterate; a lane that
1085/// exhausts `max_iterations` reports [`RootStatus::MaxIterations`] and its best
1086/// iterate, which [`RootSolution::root`] still refuses to hand out as a root.
1087/// [`RootBatch::roots`] turns any failure into an `Err`.
1088///
1089/// # Verification
1090///
1091/// *Methodology.* The batch is checked against closed-form analytic roots, which
1092/// exist for the test residuals and so are an exact oracle rather than another
1093/// implementation. `(x - 1)(x - 2)(x - 3)` is solved on brackets isolating each
1094/// of its three known roots, and `x^2 - k` against `sqrt(k)`; the pass criterion
1095/// is `|x_computed - x_analytic| <= 1e-12` and `status == Converged` on every
1096/// lane.
1097///
1098/// *Results, measured 2026-08-12 by `bracketed_matches_analytic_cubic_roots` and
1099/// `brent_matches_analytic_sqrt` in `parallel/tests.rs`, release build:* worst
1100/// absolute error over the three cubic roots **0.000000e0** for *both*
1101/// [`RootMethod::Brent`] and [`RootMethod::Bisection`] — every lane recovered
1102/// 1.0, 2.0 and 3.0 exactly — and worst absolute error over the 64 `sqrt(k)`
1103/// lanes **1.776357e-15**. All lanes converged. *Interpretation:* the integer
1104/// cubic roots are exactly representable and both methods land on them bit for
1105/// bit; the `sqrt(k)` roots are irrational, and 1.78e-15 is 8 units in the last
1106/// place at `k = 64`, i.e. the rounding floor. The iteration contributes no
1107/// error of its own beyond the residual's conditioning.
1108///
1109/// # Example
1110///
1111/// ```rust
1112/// use outram_foam_basic_lib::compute::ComputeBackend;
1113/// use outram_foam_basic_lib::math::parallel::{
1114///     solve_bracketed_batch, RootMethod, RootProblem, RootSettings,
1115/// };
1116///
1117/// // Three lanes, each isolating one root of (x-1)(x-2)(x-3).
1118/// let cubic = |x: f64| (x - 1.0) * (x - 2.0) * (x - 3.0);
1119/// let problems = [
1120///     RootProblem::new(0.5, 1.5),
1121///     RootProblem::new(1.5, 2.5),
1122///     RootProblem::new(2.5, 3.5),
1123/// ];
1124///
1125/// let batch = solve_bracketed_batch(
1126///     &problems,
1127///     RootMethod::Brent,
1128///     RootSettings::default(),
1129///     ComputeBackend::CpuMulti,
1130///     |_, x| cubic(x),
1131/// );
1132///
1133/// let roots = batch.roots().expect("all lanes converge");
1134/// for (got, want) in roots.iter().zip([1.0, 2.0, 3.0]) {
1135///     assert!((got - want).abs() < 1e-12, "got {got}, want {want}");
1136/// }
1137/// ```
1138#[must_use]
1139pub fn solve_bracketed_batch<F>(
1140    problems: &[RootProblem],
1141    method: RootMethod,
1142    settings: RootSettings,
1143    backend: ComputeBackend,
1144    f: F,
1145) -> RootBatch
1146where
1147    F: Fn(usize, f64) -> f64 + Sync,
1148{
1149    solve_bracketed_batch_min(
1150        problems,
1151        method,
1152        settings,
1153        backend,
1154        ROOT_BATCH_MIN_PROBLEMS,
1155        f,
1156    )
1157}
1158
1159/// [`solve_bracketed_batch`] with the size floor supplied by the caller.
1160///
1161/// Exists so the crossover benchmark can measure the multi-CPU path *below*
1162/// [`ROOT_BATCH_MIN_PROBLEMS`] — the only way to find where the crossover
1163/// actually is — and so the cross-backend bitwise tests are not vacuous on small
1164/// batches. Not public: production callers get the measured floor.
1165pub(crate) fn solve_bracketed_batch_min<F>(
1166    problems: &[RootProblem],
1167    method: RootMethod,
1168    settings: RootSettings,
1169    backend: ComputeBackend,
1170    min_problems: usize,
1171    f: F,
1172) -> RootBatch
1173where
1174    F: Fn(usize, f64) -> f64 + Sync,
1175{
1176    let n = problems.len();
1177    let solutions: Vec<RootSolution> = match effective_backend(backend, n, min_problems) {
1178        #[cfg(feature = "parallel")]
1179        ComputeBackend::CpuMulti => problems
1180            .par_iter()
1181            .enumerate()
1182            // No `min_len` floor: per-lane iteration counts vary, so the
1183            // splitter is left free to steal down to a single lane.
1184            .map(|(i, p)| solve_one_bracketed(i, *p, method, settings, &f))
1185            .collect(),
1186        _ => problems
1187            .iter()
1188            .enumerate()
1189            .map(|(i, p)| solve_one_bracketed(i, *p, method, settings, &f))
1190            .collect(),
1191    };
1192    RootBatch { solutions }
1193}
1194
1195/// Solve `N` independent root problems by bracket-safeguarded Newton iteration,
1196/// on the chosen backend.
1197///
1198/// Use this when the derivative is cheap to obtain alongside the value — which
1199/// for a thermodynamic inversion it is, because `dh/dT` is the specific heat the
1200/// same evaluation already computed. It is typically 2-4x fewer function
1201/// evaluations than [`RootMethod::Brent`] on a smooth residual.
1202///
1203/// # Why it is safeguarded, and not bare Newton
1204///
1205/// Bare Newton has no guarantee at all: a small derivative throws the iterate to
1206/// infinity, and a nearby inflection makes it cycle. Neither failure is
1207/// acceptable in a per-cell inversion where one bad cell out of a million stalls
1208/// the timestep. This solver therefore keeps the bracket that Newton lacks: it
1209/// takes the Newton step **only** when the step lands inside the current bracket
1210/// and is shrinking the interval fast enough, and bisects otherwise. The result
1211/// converges on anything a bisection would converge on, at Newton's rate
1212/// wherever Newton behaves.
1213///
1214/// # Arguments
1215///
1216/// - `problems` — one [`RootProblem`] per lane. Both the bracket **and** the
1217///   `guess` are used: the bracket safeguards, the guess starts the iteration.
1218///   A guess outside the bracket or non-finite is replaced by the midpoint.
1219/// - `settings` — tolerances and iteration cap. The abscissa tolerance is tested
1220///   against the **step**, `|dx| <= x_tol_abs + x_tol_rel * |x|`.
1221/// - `backend` — requested execution backend; see [`root_batch_backend_for`].
1222/// - `fdf` — `fdf(i, x)` returns `(value, derivative)` of lane `i`'s residual at
1223///   `x`. Returning both together is deliberate: an enthalpy and its specific
1224///   heat come out of one polynomial evaluation, and asking for them separately
1225///   would double the cost. Must be a pure deterministic function; see the
1226///   module-level "Determinism" section.
1227///
1228/// # Returns
1229///
1230/// A [`RootBatch`] with one [`RootSolution`] per problem, in problem order.
1231///
1232/// # Determinism
1233///
1234/// Bit-for-bit identical across backends and thread counts for a pure `fdf`, on
1235/// the same terms as [`solve_bracketed_batch`].
1236///
1237/// # Non-convergence
1238///
1239/// As [`solve_bracketed_batch`], plus one Newton-specific case: a zero or
1240/// non-finite derivative is **not** a failure — the lane simply bisects that
1241/// iteration and carries on. Only a non-finite *value* stops the lane, with
1242/// [`RootStatus::NotFinite`].
1243///
1244/// # Verification
1245///
1246/// *Methodology.* Checked against the analytic root of `x^2 - k = 0`, i.e.
1247/// `sqrt(k)`, over 64 lanes with `k` spread across `[1, 65]`, and against the
1248/// three known roots of `(x - 1)(x - 2)(x - 3)`; pass criterion
1249/// `|x_computed - x_analytic| <= 1e-12` with `status == Converged` on every
1250/// lane. Separately checked against [`RootMethod::Brent`] on the same problems,
1251/// with the iteration counts of both recorded.
1252///
1253/// *Results, measured 2026-08-12 by `newton_matches_analytic_roots` and
1254/// `newton_beats_brent_on_iterations` in `parallel/tests.rs`, release build:*
1255/// worst absolute error over the 64 `sqrt(k)` lanes **8.881784e-16**; worst over
1256/// the three cubic roots **0.000000e0** (all three recovered exactly); mean
1257/// iterations **7.73** for safeguarded Newton against **13.14** for Brent on the
1258/// same 64 lanes. *Interpretation:* Newton reaches the same rounding-floor
1259/// accuracy — in fact 2x better than Brent on the `sqrt(k)` family — in 59% of
1260/// Brent's iterations, which is the expected quadratic-versus-superlinear margin
1261/// and is the reason to pay for a derivative. Note the counts include the
1262/// safeguard's bisection steps, which is why they are higher than a textbook
1263/// bare-Newton count on the same problem.
1264///
1265/// # Example — the `uom` boundary
1266///
1267/// The batch is dimensionless, and the caller converts at its edge. This lane
1268/// inverts a linear `h(T)` relation for a `ThermodynamicTemperature`, keeping
1269/// `uom` typing on both sides of the call:
1270///
1271/// ```rust
1272/// use outram_foam_basic_lib::compute::ComputeBackend;
1273/// use outram_foam_basic_lib::math::parallel::{
1274///     solve_newton_batch, RootProblem, RootSettings,
1275/// };
1276/// use uom::si::f64::{AvailableEnergy, ThermodynamicTemperature};
1277/// use uom::si::available_energy::joule_per_kilogram;
1278/// use uom::si::thermodynamic_temperature::kelvin;
1279///
1280/// // h(T) = cp * T with cp = 1000 J/(kg K); invert for two target enthalpies.
1281/// let cp = 1000.0_f64; // J / (kg K)
1282/// let targets = [
1283///     AvailableEnergy::new::<joule_per_kilogram>(400_000.0),
1284///     AvailableEnergy::new::<joule_per_kilogram>(900_000.0),
1285/// ];
1286///
1287/// // Convert in: brackets in kelvin, as plain f64.
1288/// let lo = ThermodynamicTemperature::new::<kelvin>(200.0);
1289/// let hi = ThermodynamicTemperature::new::<kelvin>(3000.0);
1290/// let problems = [
1291///     RootProblem::new(lo.get::<kelvin>(), hi.get::<kelvin>()),
1292///     RootProblem::new(lo.get::<kelvin>(), hi.get::<kelvin>()),
1293/// ];
1294///
1295/// let batch = solve_newton_batch(
1296///     &problems,
1297///     RootSettings::default(),
1298///     ComputeBackend::CpuMulti,
1299///     |i, t| (cp * t - targets[i].get::<joule_per_kilogram>(), cp),
1300/// );
1301///
1302/// // Convert out: back to a typed temperature.
1303/// let temperatures: Vec<ThermodynamicTemperature> = batch
1304///     .roots()
1305///     .expect("both lanes converge")
1306///     .into_iter()
1307///     .map(ThermodynamicTemperature::new::<kelvin>)
1308///     .collect();
1309///
1310/// assert!((temperatures[0].get::<kelvin>() - 400.0).abs() < 1e-9);
1311/// assert!((temperatures[1].get::<kelvin>() - 900.0).abs() < 1e-9);
1312/// ```
1313#[must_use]
1314pub fn solve_newton_batch<F>(
1315    problems: &[RootProblem],
1316    settings: RootSettings,
1317    backend: ComputeBackend,
1318    fdf: F,
1319) -> RootBatch
1320where
1321    F: Fn(usize, f64) -> (f64, f64) + Sync,
1322{
1323    solve_newton_batch_min(problems, settings, backend, ROOT_BATCH_MIN_PROBLEMS, fdf)
1324}
1325
1326/// [`solve_newton_batch`] with the size floor supplied by the caller; see
1327/// [`solve_bracketed_batch_min`] for why the `_min` variants exist.
1328pub(crate) fn solve_newton_batch_min<F>(
1329    problems: &[RootProblem],
1330    settings: RootSettings,
1331    backend: ComputeBackend,
1332    min_problems: usize,
1333    fdf: F,
1334) -> RootBatch
1335where
1336    F: Fn(usize, f64) -> (f64, f64) + Sync,
1337{
1338    let n = problems.len();
1339    let solutions: Vec<RootSolution> = match effective_backend(backend, n, min_problems) {
1340        #[cfg(feature = "parallel")]
1341        ComputeBackend::CpuMulti => problems
1342            .par_iter()
1343            .enumerate()
1344            .map(|(i, p)| solve_one_newton(i, *p, settings, &fdf))
1345            .collect(),
1346        _ => problems
1347            .iter()
1348            .enumerate()
1349            .map(|(i, p)| solve_one_newton(i, *p, settings, &fdf))
1350            .collect(),
1351    };
1352    RootBatch { solutions }
1353}
1354
1355// ── Per-lane kernels — one implementation, both backends ─────────────────────
1356
1357/// Evaluate both bracket ends and classify the lane before any iteration.
1358///
1359/// Returns `Err(solution)` when the lane is already decided — an invalid or
1360/// non-finite bracket, a root sitting exactly on an endpoint, or a bracket that
1361/// does not straddle a sign change. Returns `Ok((a, fa, b, fb))` otherwise, with
1362/// `fa` and `fb` finite, nonzero and of opposite sign.
1363#[inline]
1364fn prepare_bracket<F>(i: usize, p: RootProblem, f: &F) -> Result<(f64, f64, f64, f64), RootSolution>
1365where
1366    F: Fn(usize, f64) -> f64,
1367{
1368    let nan = f64::NAN;
1369    if !p.lo.is_finite() || !p.hi.is_finite() {
1370        return Err(RootSolution::failed(
1371            RootStatus::InvalidBracket,
1372            nan,
1373            nan,
1374            nan,
1375            0,
1376        ));
1377    }
1378
1379    let (a, b) = (p.lo, p.hi);
1380    let fa = f(i, a);
1381    let fb = f(i, b);
1382    let width = (b - a).abs();
1383
1384    if !fa.is_finite() {
1385        return Err(RootSolution::failed(RootStatus::NotFinite, a, fa, width, 0));
1386    }
1387    if !fb.is_finite() {
1388        return Err(RootSolution::failed(RootStatus::NotFinite, b, fb, width, 0));
1389    }
1390    // A root exactly on an endpoint is a root, not a degenerate bracket.
1391    if fa == 0.0 {
1392        return Err(RootSolution::converged_at(a, fa, width, 0));
1393    }
1394    if fb == 0.0 {
1395        return Err(RootSolution::converged_at(b, fb, width, 0));
1396    }
1397    // Same sign at both ends: no sign change is guaranteed inside, so refuse.
1398    if (fa > 0.0) == (fb > 0.0) {
1399        return Err(RootSolution::failed(
1400            RootStatus::NotBracketed,
1401            nan,
1402            nan,
1403            width,
1404            0,
1405        ));
1406    }
1407    Ok((a, fa, b, fb))
1408}
1409
1410/// The single-lane derivative-free solve that **both** backends run.
1411///
1412/// `#[inline]` so the serial loop and the `rayon` map compile to the same inner
1413/// code — part of why the two backends agree bit for bit.
1414#[inline]
1415fn solve_one_bracketed<F>(
1416    i: usize,
1417    p: RootProblem,
1418    method: RootMethod,
1419    settings: RootSettings,
1420    f: &F,
1421) -> RootSolution
1422where
1423    F: Fn(usize, f64) -> f64,
1424{
1425    let (a, fa, b, fb) = match prepare_bracket(i, p, f) {
1426        Ok(v) => v,
1427        Err(decided) => return decided,
1428    };
1429    match method {
1430        RootMethod::Bisection => bisect_lane(i, a, fa, b, settings, f),
1431        RootMethod::Brent => brent_lane(i, a, fa, b, fb, settings, f),
1432    }
1433}
1434
1435/// Repeated bisection of a bracket known to straddle a sign change.
1436#[inline]
1437fn bisect_lane<F>(
1438    i: usize,
1439    mut a: f64,
1440    mut fa: f64,
1441    mut b: f64,
1442    settings: RootSettings,
1443    f: &F,
1444) -> RootSolution
1445where
1446    F: Fn(usize, f64) -> f64,
1447{
1448    let mut m = a + 0.5 * (b - a);
1449    let mut fm = fa;
1450    for iteration in 1..=settings.max_iterations {
1451        m = a + 0.5 * (b - a);
1452        fm = f(i, m);
1453        if !fm.is_finite() {
1454            return RootSolution::failed(RootStatus::NotFinite, m, fm, (b - a).abs(), iteration);
1455        }
1456        let width = (b - a).abs();
1457        if settings.residual_small_enough(fm) || width <= settings.x_tol(m) {
1458            return RootSolution::converged_at(m, fm, width, iteration);
1459        }
1460        if (fm > 0.0) == (fa > 0.0) {
1461            a = m;
1462            fa = fm;
1463        } else {
1464            b = m;
1465        }
1466    }
1467    RootSolution::failed(
1468        RootStatus::MaxIterations,
1469        m,
1470        fm,
1471        (b - a).abs(),
1472        settings.max_iterations,
1473    )
1474}
1475
1476/// Brent's method on a bracket known to straddle a sign change.
1477///
1478/// Implemented from the published description of the algorithm (Brent 1973,
1479/// chapter 4): inverse quadratic interpolation through the three most recent
1480/// iterates, the secant rule when two of them coincide in value, and a
1481/// bisection step whenever the interpolated point falls outside the middle of
1482/// the bracket or the interval is not shrinking fast enough. The bracket
1483/// invariant — `b` is the best estimate, `a` the counter-bracket, and the
1484/// residual changes sign between them — is maintained at every step, so the
1485/// method inherits bisection's guarantee.
1486#[inline]
1487fn brent_lane<F>(
1488    i: usize,
1489    a_in: f64,
1490    fa_in: f64,
1491    b_in: f64,
1492    fb_in: f64,
1493    settings: RootSettings,
1494    f: &F,
1495) -> RootSolution
1496where
1497    F: Fn(usize, f64) -> f64,
1498{
1499    // `b` is kept as the better of the two estimates.
1500    let (mut a, mut fa, mut b, mut fb) = if fa_in.abs() < fb_in.abs() {
1501        (b_in, fb_in, a_in, fa_in)
1502    } else {
1503        (a_in, fa_in, b_in, fb_in)
1504    };
1505
1506    // `c` is the previous iterate, `d` the one before that.
1507    let mut c = a;
1508    let mut fc = fa;
1509    let mut d = a;
1510    let mut used_bisection = true;
1511
1512    for iteration in 1..=settings.max_iterations {
1513        let width = (b - a).abs();
1514        let tol = settings.x_tol(b);
1515        if settings.residual_small_enough(fb) || width <= tol {
1516            return RootSolution::converged_at(b, fb, width, iteration - 1);
1517        }
1518
1519        // Interpolate: inverse quadratic through (a, fa), (b, fb), (c, fc) when
1520        // the three residuals are distinct; otherwise the secant through the
1521        // two most recent.
1522        let mut s = if fa != fc && fb != fc {
1523            a * fb * fc / ((fa - fb) * (fa - fc))
1524                + b * fa * fc / ((fb - fa) * (fb - fc))
1525                + c * fa * fb / ((fc - fa) * (fc - fb))
1526        } else {
1527            b - fb * (b - a) / (fb - fa)
1528        };
1529
1530        // Reject the interpolated step unless it lands in the middle half-ish of
1531        // the bracket and is shrinking the interval at least geometrically.
1532        let quarter = (3.0 * a + b) / 4.0;
1533        let (lo, hi) = if quarter < b {
1534            (quarter, b)
1535        } else {
1536            (b, quarter)
1537        };
1538        let outside = !(s > lo && s < hi);
1539        let stalling = if used_bisection {
1540            (s - b).abs() >= (b - c).abs() * 0.5 || (b - c).abs() < tol
1541        } else {
1542            (s - b).abs() >= (c - d).abs() * 0.5 || (c - d).abs() < tol
1543        };
1544        if outside || stalling || !s.is_finite() {
1545            s = a + 0.5 * (b - a);
1546            used_bisection = true;
1547        } else {
1548            used_bisection = false;
1549        }
1550
1551        let fs = f(i, s);
1552        if !fs.is_finite() {
1553            return RootSolution::failed(RootStatus::NotFinite, s, fs, width, iteration);
1554        }
1555
1556        d = c;
1557        c = b;
1558        fc = fb;
1559
1560        // Keep the sign change: replace whichever end shares `s`'s sign.
1561        if (fa > 0.0) != (fs > 0.0) {
1562            b = s;
1563            fb = fs;
1564        } else {
1565            a = s;
1566            fa = fs;
1567        }
1568        // Re-establish "b is the better estimate".
1569        if fa.abs() < fb.abs() {
1570            std::mem::swap(&mut a, &mut b);
1571            std::mem::swap(&mut fa, &mut fb);
1572        }
1573        // An exact hit ends it immediately, whatever the tolerances say.
1574        if fb == 0.0 {
1575            return RootSolution::converged_at(b, fb, (b - a).abs(), iteration);
1576        }
1577    }
1578
1579    RootSolution::failed(
1580        RootStatus::MaxIterations,
1581        b,
1582        fb,
1583        (b - a).abs(),
1584        settings.max_iterations,
1585    )
1586}
1587
1588/// The single-lane bracket-safeguarded Newton solve that both backends run.
1589///
1590/// Keeps `[x_lo, x_hi]` oriented so the residual is negative at `x_lo` and
1591/// positive at `x_hi`. Each iteration takes the Newton step when it lands
1592/// inside that interval and is at least halving the step size, and bisects
1593/// otherwise — so a zero, non-finite or misleading derivative costs one wasted
1594/// bisection rather than a diverged lane.
1595#[inline]
1596fn solve_one_newton<F>(i: usize, p: RootProblem, settings: RootSettings, fdf: &F) -> RootSolution
1597where
1598    F: Fn(usize, f64) -> (f64, f64) + Sync,
1599{
1600    // Reuse the derivative-free bracket preparation by projecting to the value.
1601    let value_only = |j: usize, x: f64| fdf(j, x).0;
1602    let (a, fa, b, _fb) = match prepare_bracket(i, p, &value_only) {
1603        Ok(v) => v,
1604        Err(decided) => return decided,
1605    };
1606
1607    // Orient so that f(x_lo) < 0 < f(x_hi).
1608    let (mut x_lo, mut x_hi) = if fa < 0.0 { (a, b) } else { (b, a) };
1609
1610    let mid = a + 0.5 * (b - a);
1611    let in_bracket = |x: f64| {
1612        let (min, max) = if a < b { (a, b) } else { (b, a) };
1613        x.is_finite() && x >= min && x <= max
1614    };
1615    let mut x = if in_bracket(p.guess) { p.guess } else { mid };
1616
1617    let (mut fx, mut dfx) = fdf(i, x);
1618    if !fx.is_finite() {
1619        return RootSolution::failed(RootStatus::NotFinite, x, fx, (b - a).abs(), 0);
1620    }
1621    if settings.residual_small_enough(fx) {
1622        return RootSolution::converged_at(x, fx, (x_hi - x_lo).abs(), 0);
1623    }
1624
1625    let mut dx_previous = (b - a).abs();
1626    let mut dx = dx_previous;
1627
1628    for iteration in 1..=settings.max_iterations {
1629        // Reject the Newton step if it would leave the bracket, if it is not at
1630        // least halving the step, or if the derivative is unusable.
1631        let leaves_bracket = ((x - x_hi) * dfx - fx) * ((x - x_lo) * dfx - fx) > 0.0;
1632        let too_slow = (2.0 * fx).abs() > (dx_previous * dfx).abs();
1633        if dfx == 0.0 || !dfx.is_finite() || leaves_bracket || too_slow {
1634            dx_previous = dx;
1635            dx = 0.5 * (x_hi - x_lo);
1636            x = x_lo + dx;
1637        } else {
1638            dx_previous = dx;
1639            dx = fx / dfx;
1640            x -= dx;
1641        }
1642
1643        let (nfx, ndfx) = fdf(i, x);
1644        if !nfx.is_finite() {
1645            return RootSolution::failed(
1646                RootStatus::NotFinite,
1647                x,
1648                nfx,
1649                (x_hi - x_lo).abs(),
1650                iteration,
1651            );
1652        }
1653        fx = nfx;
1654        dfx = ndfx;
1655
1656        if settings.residual_small_enough(fx) || dx.abs() <= settings.x_tol(x) {
1657            return RootSolution::converged_at(x, fx, (x_hi - x_lo).abs(), iteration);
1658        }
1659
1660        if fx < 0.0 {
1661            x_lo = x;
1662        } else {
1663            x_hi = x;
1664        }
1665    }
1666
1667    RootSolution::failed(
1668        RootStatus::MaxIterations,
1669        x,
1670        fx,
1671        (x_hi - x_lo).abs(),
1672        settings.max_iterations,
1673    )
1674}
1675
1676// ── Closed-form polynomial roots, batched ────────────────────────────────────
1677
1678/// Roots of `N` independent linear equations `a x + b = 0`, on the chosen
1679/// backend.
1680///
1681/// A batched wrapper over [`LinearEqn::roots`] — the per-equation closed-form
1682/// solver is unchanged and is not reimplemented here. Each lane is an
1683/// independent handful of floating-point operations, so this is the
1684/// branch-lightest kernel in the module.
1685///
1686/// # Arguments
1687///
1688/// - `eqns` — one [`LinearEqn`] per lane. Coefficients are dimensionless `f64`,
1689///   as everywhere in [`crate::polynomial`].
1690/// - `backend` — requested execution backend; see [`poly_roots_backend_for`] for
1691///   what will actually run.
1692///
1693/// # Returns
1694///
1695/// One [`Roots<1>`] per equation, in input order. Degenerate cases are tagged
1696/// rather than hidden: `|a| < VSMALL` yields a `Nan`-tagged root and an
1697/// overflowing quotient yields `PosInf`/`NegInf`, exactly as the scalar solver
1698/// does.
1699///
1700/// # Determinism
1701///
1702/// Bit-for-bit identical to calling [`LinearEqn::roots`] in a loop, on every
1703/// backend and at any thread count: each lane is one independent closed-form
1704/// evaluation.
1705///
1706/// # Example
1707///
1708/// ```rust
1709/// use outram_foam_basic_lib::compute::ComputeBackend;
1710/// use outram_foam_basic_lib::math::parallel::linear_roots_batch;
1711/// use outram_foam_basic_lib::polynomial::{LinearEqn, RootType};
1712///
1713/// // 2x - 4 = 0  and  3x + 9 = 0
1714/// let eqns = [LinearEqn::new(2.0, -4.0), LinearEqn::new(3.0, 9.0)];
1715/// let roots = linear_roots_batch(&eqns, ComputeBackend::CpuMulti);
1716///
1717/// assert_eq!(roots[0].root_type(0), RootType::Real);
1718/// assert_eq!(roots[0].get(0), 2.0);
1719/// assert_eq!(roots[1].get(0), -3.0);
1720/// ```
1721#[must_use]
1722pub fn linear_roots_batch(eqns: &[LinearEqn], backend: ComputeBackend) -> Vec<Roots<1>> {
1723    linear_roots_batch_min(eqns, backend, POLY_ROOTS_MIN_EQUATIONS)
1724}
1725
1726/// [`linear_roots_batch`] with a caller-supplied size floor; see
1727/// [`solve_bracketed_batch_min`] for why the `_min` variants exist.
1728pub(crate) fn linear_roots_batch_min(
1729    eqns: &[LinearEqn],
1730    backend: ComputeBackend,
1731    min_equations: usize,
1732) -> Vec<Roots<1>> {
1733    match effective_backend(backend, eqns.len(), min_equations) {
1734        #[cfg(feature = "parallel")]
1735        ComputeBackend::CpuMulti => eqns
1736            .par_iter()
1737            .with_min_len(POLY_BLOCK)
1738            .map(LinearEqn::roots)
1739            .collect(),
1740        _ => eqns.iter().map(LinearEqn::roots).collect(),
1741    }
1742}
1743
1744/// Roots of `N` independent quadratic equations `a x^2 + b x + c = 0`, on the
1745/// chosen backend.
1746///
1747/// A batched wrapper over [`QuadraticEqn::roots`], which uses a
1748/// Kahan-compensated discriminant; the per-equation solver is unchanged and is
1749/// not reimplemented here.
1750///
1751/// # Arguments
1752///
1753/// - `eqns` — one [`QuadraticEqn`] per lane, dimensionless coefficients.
1754/// - `backend` — requested execution backend.
1755///
1756/// # Returns
1757///
1758/// One [`Roots<2>`] per equation, in input order. Two `Real` roots when the
1759/// discriminant is positive; a `Complex`-tagged (real part, imaginary part) pair
1760/// when it is negative; one `Real` plus one `Nan` when `|a|` underflows and the
1761/// equation is really linear.
1762///
1763/// # Determinism
1764///
1765/// Bit-for-bit identical to calling [`QuadraticEqn::roots`] in a loop, on every
1766/// backend and at any thread count.
1767///
1768/// # Verification
1769///
1770/// *Methodology.* Every lane's real roots are substituted back into their own
1771/// equation and the residual `|a x^2 + b x + c|` compared against zero, over
1772/// 4 096 equations built as `(x - r1)(x - r2)` with pseudorandom real roots on
1773/// `[-8, 8)` from a fixed-seed xorshift64\*; pass criterion: residual
1774/// `<= 1e-9` and bitwise equality against the per-equation scalar solver.
1775///
1776/// *Result, measured 2026-08-12 by `quadratic_batch_residuals_are_analytic` in
1777/// `parallel/tests.rs`, release build:* worst residual **7.105427e-15** over
1778/// 8 192 real roots; bitwise equality against the scalar solver held on every
1779/// lane. *Interpretation:* the batching adds nothing to the scalar solver's own
1780/// error, which is what "bit-for-bit identical" means measured rather than
1781/// argued.
1782///
1783/// # Example
1784///
1785/// ```rust
1786/// use outram_foam_basic_lib::compute::ComputeBackend;
1787/// use outram_foam_basic_lib::math::parallel::quadratic_roots_batch;
1788/// use outram_foam_basic_lib::polynomial::{QuadraticEqn, RootType};
1789///
1790/// // (x - 1)(x - 2) = x^2 - 3x + 2
1791/// let eqns = [QuadraticEqn::new(1.0, -3.0, 2.0)];
1792/// let roots = quadratic_roots_batch(&eqns, ComputeBackend::CpuMulti);
1793///
1794/// let mut real: Vec<f64> = (0..2)
1795///     .filter(|&k| roots[0].root_type(k) == RootType::Real)
1796///     .map(|k| roots[0].get(k))
1797///     .collect();
1798/// real.sort_by(f64::total_cmp);
1799/// assert!((real[0] - 1.0).abs() < 1e-12);
1800/// assert!((real[1] - 2.0).abs() < 1e-12);
1801/// ```
1802#[must_use]
1803pub fn quadratic_roots_batch(eqns: &[QuadraticEqn], backend: ComputeBackend) -> Vec<Roots<2>> {
1804    quadratic_roots_batch_min(eqns, backend, POLY_ROOTS_MIN_EQUATIONS)
1805}
1806
1807/// [`quadratic_roots_batch`] with a caller-supplied size floor; see
1808/// [`solve_bracketed_batch_min`] for why the `_min` variants exist.
1809pub(crate) fn quadratic_roots_batch_min(
1810    eqns: &[QuadraticEqn],
1811    backend: ComputeBackend,
1812    min_equations: usize,
1813) -> Vec<Roots<2>> {
1814    match effective_backend(backend, eqns.len(), min_equations) {
1815        #[cfg(feature = "parallel")]
1816        ComputeBackend::CpuMulti => eqns
1817            .par_iter()
1818            .with_min_len(POLY_BLOCK)
1819            .map(QuadraticEqn::roots)
1820            .collect(),
1821        _ => eqns.iter().map(QuadraticEqn::roots).collect(),
1822    }
1823}
1824
1825/// Roots of `N` independent cubic equations `a x^3 + b x^2 + c x + d = 0`, on
1826/// the chosen backend.
1827///
1828/// A batched wrapper over [`CubicEqn::roots`] — the depressed-cubic Cardano
1829/// solver with Kahan-compensated discriminants, unchanged and not reimplemented
1830/// here.
1831///
1832/// This is the kernel a cubic-equation-of-state caller wants: a Peng-Robinson or
1833/// Redlich-Kwong compressibility factor is the root of a cubic in `Z`, one cubic
1834/// per cell, and the whole field can be solved in a single call.
1835///
1836/// # Arguments
1837///
1838/// - `eqns` — one [`CubicEqn`] per lane, dimensionless coefficients.
1839/// - `backend` — requested execution backend; see [`poly_roots_backend_for`].
1840///
1841/// # Returns
1842///
1843/// One [`Roots<3>`] per equation, in input order. Three `Real` roots, or one
1844/// `Real` plus a `Complex`-tagged (real, imaginary) pair, or a triple root, or —
1845/// when `|a|` underflows — the quadratic's roots plus a `Nan` slot. The tags are
1846/// the caller's guide; do not read a slot without checking
1847/// [`Roots::root_type`].
1848///
1849/// There is no `_into` variant, unlike [`crate::ldu_matrix::parallel`]'s
1850/// kernels: [`Roots`] has no `Default`, so a caller-owned output buffer would
1851/// have to be pre-filled with a dummy value, and the per-lane Cardano solve
1852/// dominates the allocation anyway.
1853///
1854/// # Determinism
1855///
1856/// Bit-for-bit identical to calling [`CubicEqn::roots`] in a loop, on every
1857/// backend and at any thread count. Each lane is one independent closed-form
1858/// evaluation; no arithmetic crosses lanes.
1859///
1860/// # Verification
1861///
1862/// *Methodology.* Cubics are constructed from **known** roots as
1863/// `(x - r1)(x - r2)(x - r3)` with `r1, r2, r3` drawn from a fixed-seed
1864/// xorshift64\* on `[-8, 8)`, expanded to coefficients — so the analytic answer
1865/// is known exactly, which makes this an oracle rather than a second opinion.
1866/// 4 096 equations. Two pass criteria: every `Real`-tagged root satisfies
1867/// `|a x^3 + b x^2 + c x + d| <= 1e-6` (loose because a cubic's residual is
1868/// steeply amplified near a triple-ish root), and every returned bit is equal to
1869/// the per-equation scalar [`CubicEqn::roots`].
1870///
1871/// *Result, measured 2026-08-12 by `cubic_batch_matches_analytic_construction`
1872/// in `parallel/tests.rs`, release build:* worst residual **3.979039e-13** over
1873/// 12 288 real roots, worst distance from the nearest constructed root
1874/// **2.079981e-10**; bitwise equality against the scalar solver held on every
1875/// lane, and (per `poly_batch_bitwise_identical_across_backends`) across
1876/// `Serial` / `CpuMulti` at 1, 2, 4 and 8 worker threads.
1877///
1878/// *Interpretation:* both figures belong to the scalar Cardano solver, not to
1879/// the batching — the bitwise-equality half of the test proves the batch adds
1880/// exactly zero error. That the worst *root displacement* (2.1e-10) is three
1881/// orders larger than the worst *residual* (4.0e-13) is the signature of an
1882/// ill-conditioned root, not of a bad solve: with roots drawn at random from
1883/// `[-8, 8)`, some triples come out nearly coincident, and near a near-double
1884/// root the cubic is flat, so a tiny residual still corresponds to a visibly
1885/// displaced abscissa. A caller solving a cubic equation of state should expect
1886/// the same behaviour near the critical point and judge accuracy by the
1887/// residual.
1888///
1889/// # Example
1890///
1891/// ```rust
1892/// use outram_foam_basic_lib::compute::ComputeBackend;
1893/// use outram_foam_basic_lib::math::parallel::cubic_roots_batch;
1894/// use outram_foam_basic_lib::polynomial::{CubicEqn, RootType};
1895///
1896/// // (x-1)(x-2)(x-3) = x^3 - 6x^2 + 11x - 6
1897/// let eqns = [CubicEqn::new(1.0, -6.0, 11.0, -6.0)];
1898/// let roots = cubic_roots_batch(&eqns, ComputeBackend::CpuMulti);
1899///
1900/// let mut real: Vec<f64> = (0..3)
1901///     .filter(|&k| roots[0].root_type(k) == RootType::Real)
1902///     .map(|k| roots[0].get(k))
1903///     .collect();
1904/// real.sort_by(f64::total_cmp);
1905/// assert_eq!(real.len(), 3);
1906/// assert!((real[0] - 1.0).abs() < 1e-9);
1907/// assert!((real[2] - 3.0).abs() < 1e-9);
1908/// ```
1909#[must_use]
1910pub fn cubic_roots_batch(eqns: &[CubicEqn], backend: ComputeBackend) -> Vec<Roots<3>> {
1911    cubic_roots_batch_min(eqns, backend, POLY_ROOTS_MIN_EQUATIONS)
1912}
1913
1914/// [`cubic_roots_batch`] with a caller-supplied size floor; see
1915/// [`solve_bracketed_batch_min`] for why the `_min` variants exist.
1916pub(crate) fn cubic_roots_batch_min(
1917    eqns: &[CubicEqn],
1918    backend: ComputeBackend,
1919    min_equations: usize,
1920) -> Vec<Roots<3>> {
1921    match effective_backend(backend, eqns.len(), min_equations) {
1922        #[cfg(feature = "parallel")]
1923        ComputeBackend::CpuMulti => eqns
1924            .par_iter()
1925            .with_min_len(POLY_BLOCK)
1926            .map(CubicEqn::roots)
1927            .collect(),
1928        _ => eqns.iter().map(CubicEqn::roots).collect(),
1929    }
1930}