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