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}