Skip to main content

outram_foam_basic_lib/math/
minimise.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 derivative-free 1-D **golden-section** extremum search on the hybrid
20//! execution backend — contract `N` independent brackets at once, serially or
21//! across CPU cores.
22//!
23//! # Provenance — this is a generalisation, not a new algorithm
24//!
25//! The algorithm, its contraction constant, its bracket-width stopping rule and
26//! its literature citation are taken from the **working, in-production**
27//! golden-section search already in this workspace:
28//!
29//! ```text
30//! crates/tampines-steam-tables/src/steam_turbine_equations/
31//!     converging_diverging_nozzles/choked_flow/
32//!     stagnation_point_outside_vle_ph_dome_multiphase.rs:67
33//!         fn golden_section_max_g(...)
34//! ```
35//!
36//! That function locates the homogeneous-equilibrium choked-flow energy-balance
37//! maximum `G(p) = rho(p,s0) * sqrt(2*(h0 - h(p,s0)))` along an isentrope and is
38//! exercised by that crate's Marviken critical-flow tests. It is cited to:
39//!
40//! > Price, C. J., & Robertson, B. L. (2012). *Golden Section Search.* In
41//! > Encyclopedia of Engineering Optimization and Heuristics (pp. 1-4).
42//! > Singapore: Springer Nature Singapore.
43//!
44//! This module carries the same citation, because it is the same method. What is
45//! added here is batching across [`ComputeBackend`], per-lane status reporting,
46//! absolute+relative tolerances in place of a hard-coded 1 Pa bracket width,
47//! non-finite handling, and the [`Sense`] switch so one kernel serves both
48//! minimisation and the maximisation the steam-tables caller actually wants.
49//!
50//! **One deliberate algorithmic deviation from the original, and it is a
51//! speed-up, not a behaviour change in exact arithmetic:** the original
52//! re-evaluates *both* interior probes on every iteration. Golden section exists
53//! precisely so that one probe of the contracted bracket coincides with a probe
54//! already evaluated, costing **one** objective evaluation per iteration rather
55//! than two — the original's own comment claims that property while its code
56//! does not implement it. This module reuses the retained probe. Consequences:
57//!
58//! - Roughly **half** the objective evaluations for the same bracket sequence,
59//!   which matters when the objective is an IF97 flash.
60//! - The retained probe is *reused* rather than *recomputed*, and
61//!   `a + gr*(b - a)` recomputed is not bit-identical to the value it replaces.
62//!   So this module's iterates can differ from the original's in the last bits.
63//!   The bracket sequence, the convergence behaviour and the located extremum to
64//!   within the requested tolerance are unchanged.
65//! - **A measured, quantified cost, recorded here rather than discovered later.**
66//!   The retained probe carries an absolute rounding error fixed at the scale of
67//!   the *older, wider* bracket, while the bracket itself keeps shrinking — so
68//!   the width's *relative* deviation from the closed form `W0 * gr^k` grows
69//!   roughly geometrically with iteration count. Measured by
70//!   `bracket_contracts_at_the_golden_ratio` (release, 2026-08-13, `W0 = 10`):
71//!   `0` at `k = 1`, `2.675637e-14` at `k = 20`, `2.251199e-11` at `k = 40`,
72//!   `2.334157e-6` at `k = 80`. Recomputing both probes — the original's form —
73//!   holds the deviation at `<= 6.7e-16` for every one of those `k`.
74//!
75//!   **CORRECTED 2026-08-13 — the original text here understated the effect on
76//!   the located abscissa by about eight orders of magnitude, and the error is
77//!   instructive.** It read: "at `k = 80` the bracket width is `1.9e-16`, so a
78//!   `2.3e-6` relative deviation is `4e-22` absolute". That arithmetic is right
79//!   about the *width* and wrong about the *answer*. The two are not the same
80//!   quantity: a last-bit difference in a probe can flip a comparison on a
81//!   near-flat objective, or change which iteration first trips the stopping
82//!   test, and the returned midpoint then shifts by up to roughly **one bracket
83//!   width at the stopping tolerance** — not by the width at `k = 80`.
84//!
85//!   Measured, not reasoned: `tampines-steam-tables` ran both forms side by side
86//!   over 158 real IAPWS-IF97 choked-flow searches and found a worst
87//!   `|Δp_crit|` of **5.990479e-1 Pa** against a 1 Pa stopping rule, and up to
88//!   **3 Pa** on a production case — about `1e8` times the `4e-22` the old text
89//!   implied. The worst relative deviation was `4.604111e-8`, and the objective
90//!   value barely moved at all: worst `|ΔG|/G = 3.429708e-13`.
91//!
92//!   **The conclusion survives; only the magnitude was wrong.** Those shifts sat
93//!   four orders inside the consuming test's `0.005` relative tolerance, the
94//!   Marviken and Moody gate outputs were byte-identical, and the objective is
95//!   what a caller actually uses. But "well inside tolerance, measured" is a
96//!   different claim from "`4e-22`, therefore ignorable", and a caller with a
97//!   tighter tolerance than 1 Pa deserves the honest figure. Tracked as
98//!   `op-8kww`.
99//!
100//!   Separately and still true: the closed form `W0 * gr^k` stops being an exact
101//!   predictor of [`MinSolution::bracket_width`] at large `k`. If you need the
102//!   width exact rather than the evaluation count halved, recompute both probes.
103//!
104//! # Golden section finds a minimum of a UNIMODAL function — read this first
105//!
106//! **On a bracket where the objective is not unimodal, golden section returns
107//! *a* local extremum, silently, with `Converged` status and no warning of any
108//! kind.** It cannot do better: it only ever compares two interior probes, so a
109//! second basin outside the surviving sub-bracket is discarded on the first
110//! iteration and can never be recovered. There is no test this module could run
111//! to detect the situation without evaluating the objective densely, which would
112//! defeat the point of using golden section at all.
113//!
114//! This is the single easiest way to get a confident wrong answer out of this
115//! module. If you do not *know* your objective is unimodal on the bracket —
116//! from the physics, not from a plot of one case — then coarse-scan first and
117//! hand golden section a bracket around the basin you want. That is exactly what
118//! the steam-tables `dome_crossing_interior_choke` caller does: a 1500-point
119//! scan finds the right basin, and golden section only refines it.
120//!
121//! # Convergence is on the BRACKET, never on the value
122//!
123//! [`MinSettings`] deliberately has **no `f_tol` field**, unlike its sibling
124//! [`crate::math::parallel::RootSettings`]. A value-based criterion such as
125//! `|f(b) - f(a)| <= f_tol` is actively wrong for minimisation, and the reason is
126//! the defining property of a minimum rather than an implementation detail: near
127//! a smooth minimum `x*`,
128//!
129//! ```text
130//! f(x* + d) - f(x*) ~= 0.5 * f''(x*) * d^2
131//! ```
132//!
133//! so the *values* agree to second order long before the *arguments* do. A
134//! function-value test therefore reports success while the abscissa is still
135//! wrong in its most significant digits. This module tests the bracket width and
136//! nothing else.
137//!
138//! # Achievable accuracy is about `sqrt(eps)`, not `eps`
139//!
140//! The same quadratic flatness bounds how well *any* derivative-free method can
141//! locate a smooth minimum. Two probes a distance `d` apart around `x*` differ in
142//! value by `~0.5*f''*d^2`; once that difference falls below the rounding noise
143//! in the *evaluated* `f`, which is `~eps*|f(x*)|`, the comparison that drives
144//! the contraction is deciding on noise. Equating the two gives
145//!
146//! ```text
147//! |x_located - x*|  ~  sqrt( 2 * eps * |f(x*)| / |f''(x*)| )
148//! ```
149//!
150//! and with `f` and `f''` both order unity that is the classical floor
151//! `sqrt(eps) ~ 1.5e-8` — see [`SQRT_EPSILON`]. This surprises people who expect
152//! the `1e-15`-ish accuracy a root finder gets on the same machine, and it is why
153//! [`MinSettings::default`] sets `x_tol_rel` to [`SQRT_EPSILON`] rather than the
154//! `1e-12` [`crate::math::parallel::RootSettings`] uses. Asking for a tighter
155//! tolerance is not rejected — it simply spends ~30 more iterations narrowing a
156//! bracket whose midpoint is no more accurate.
157//!
158//! **Read the formula, not just the headline `sqrt(eps)`.** The floor is set by
159//! the objective's *relative precision near its own extremum*, not by golden
160//! section. Two consequences, both measured by
161//! `flat_minimum_exposes_the_sqrt_eps_limit` in `minimise/tests.rs` on 64 lanes
162//! with the tolerance driven below every arithmetic floor (release, 2026-08-13):
163//!
164//! | Objective | Predicted floor | Measured worst `\|x - x0\|` |
165//! |---|---|---|
166//! | `1 + (x - x0)^2` — order-unity value | `sqrt(eps) = 1.490116e-8` | **1.053671e-8** |
167//! | `1 + (x - x0)^4` — flatter than quadratic | `eps^(1/4) = 1.220703e-4` | **1.026485e-4** |
168//! | `(x - x0)^2` — value is exactly `0` | *none of the above* | **8.881784e-16** |
169//!
170//! The first two rows land just under their predicted floors, so the theory is a
171//! usable bound rather than a story. The **flat objective is 9 742x worse** than
172//! the quadratic one on identical lanes, brackets and settings — that is the
173//! penalty for a minimum that is flatter than quadratic, and every one of those
174//! lanes still reported `Converged` with a bracket width that looked tight.
175//!
176//! The third row is the one that stops `sqrt(eps)` being memorised as a universal
177//! constant: an objective whose minimum value is exactly zero, evaluated so that
178//! its relative precision survives (`d*d` is exact to a half-ULP in `d`), has no
179//! order-unity baseline to be swamped by and reaches **8.9e-16**, seven orders
180//! better than the "floor". A physical objective — a mass flux, a residual with a
181//! floor, an enthalpy — is the first row, not the third.
182//!
183//! # Hybrid means dispatch, not two APIs
184//!
185//! [`golden_section_batch`] takes a [`ComputeBackend`] parameter. There is no
186//! `golden_section_batch_parallel()` sibling. With the `parallel` feature off,
187//! [`ComputeBackend::CpuMulti`] resolves down to [`ComputeBackend::Serial`]
188//! through [`ComputeBackend::resolve`] and the answer is unchanged — bit for
189//! bit, not merely close. There is no `Gpu` kernel here yet, so a `Gpu` request
190//! degrades to the best available CPU path.
191//!
192//! # Determinism — bitwise identical across backends and thread counts
193//!
194//! **This module returns bit-for-bit identical output on
195//! [`ComputeBackend::Serial`] and [`ComputeBackend::CpuMulti`], at any thread
196//! count, on every run**, provided the caller's objective is a deterministic pure
197//! function of `(index, x)`.
198//!
199//! The argument is the same one [`crate::math::parallel`] makes and it is
200//! stronger here than for a reduction: lane `i`'s answer is a pure function of
201//! lane `i`'s bracket and objective evaluations, and **no arithmetic crosses
202//! lanes at all**. A parallel sum has to re-associate `a + b + c` and
203//! floating-point addition is not associative; a batch of independent
204//! contractions has nothing to re-associate. Both backends call the same
205//! `#[inline]` per-lane kernel; only the identity of the calling thread differs.
206//!
207//! Verified by the `bitwise_*` tests in `minimise/tests.rs`, which compare serial
208//! against `rayon` pools of 1, 2, 4 and 8 workers on 2 048 lanes deliberately
209//! built with wildly varying per-lane iteration counts. **Measured 2026-08-13
210//! (release, `--features parallel`, 4 logical cores): bit-identical on every
211//! observable field of every lane, at all four thread counts, for both
212//! [`Sense`] variants.**
213//!
214//! The `#[ignore]`d `minimise_batch_thread_scaling_benchmark` re-asserts the same
215//! identity while timing it, on 65 536 imbalanced lanes, best of 7, with a second
216//! independent run alongside:
217//!
218//! | Worker threads | Time | Speed-up | (repeat) | Bitwise vs serial |
219//! |---|---|---|---|---|
220//! | *serial reference* | 17883.35 us | 1.00x | 1.00x | — |
221//! | 1 | 16192.92 us | 1.10x | 1.07x | identical |
222//! | 2 | 10206.05 us | 1.75x | 1.52x | identical |
223//! | 4 | 11428.69 us | 1.56x | 1.54x | identical |
224//! | 8 | 5708.41 us | 3.13x | 2.31x | identical |
225//!
226//! The "identical" column is the determinism claim measured rather than argued,
227//! and it is asserted by the benchmark itself, not merely printed. **The timings
228//! are noisy and should not be read as a scaling study** — the 4-thread row being
229//! slower than the 2-thread row in run 1 is not a real effect, and the machine
230//! was not idle (a concurrent build was running in another checkout). One
231//! machine, four logical cores, one batch, two runs; nothing measured on Android
232//! hardware or on a many-core server.
233//!
234//! The one way a caller can break this is to supply an objective that is not a
235//! pure function — one that reads a random number generator, accumulates into
236//! shared interior-mutable state, or depends on the calling thread. The `Sync`
237//! bound permits it; this contract forbids it.
238//!
239//! # Non-convergence is reported, never swallowed
240//!
241//! - Every lane carries a [`MinStatus`], reachable through [`MinBatch::solutions`].
242//! - [`MinSolution::extremum`] returns `Option<f64>` — `Some(x)` **only** when
243//!   that lane converged. The diagnostic iterate is behind the
244//!   deliberately-named [`MinSolution::last_iterate`].
245//! - [`MinBatch::extrema`] is the all-or-nothing path: it returns
246//!   `Err(`[`MinBatchFailure`]`)` naming the failure count and the first failing
247//!   lane, rather than a plausible-looking `Vec<f64>`.
248//!
249//! **A non-converged lane is never handed back as a bracket endpoint dressed up
250//! as a minimum.** A lane that ran out of iterations reports its current bracket
251//! midpoint with [`MinStatus::MaxIterations`], which [`MinSolution::extremum`]
252//! still refuses to return; a lane with a non-finite bracket end reports
253//! [`MinStatus::InvalidBracket`] and a `NaN` iterate, because there is no honest
254//! number to give.
255//!
256//! # What is deliberately NOT here: a Brent parabolic-interpolation minimiser
257//!
258//! Bead `op-yvj.4.3` offers one as an optional CPU-only faster path. It is not
259//! implemented here, on purpose, because **this workspace already contains two
260//! of them** and a third would be the exact drift defect the workspace's
261//! "Search before building" rule exists to prevent:
262//!
263//! | Existing implementation | Provenance |
264//! |---|---|
265//! | `outram-park-fork-dwsim-libs::petroleum::fitting::brent_minimize` | independent implementation from Brent (1973) ch. 5, standing in for DWSIM's `MathEx.BrentOpt.BrentMinimize` |
266//! | `outram-park-fork-dwsim-libs::columns::shortcut::brent_minimize` (private) | faithful port of DWSIM `BrentMinimize.vb:176-295` |
267//!
268//! Both are `f64`-scalar, single-problem, and neither is batched — so there is a
269//! real case for a batched Brent minimiser eventually living *here*, since
270//! `outram-foam-basic-lib` sits below `dwsim-libs` and the dependency could only
271//! ever flow this way. That is a maintainer decision about which of three
272//! implementations becomes canonical, not something to settle by quietly adding
273//! a fourth. Brent is also explicitly excluded from the GPU by the bead: its
274//! branching destroys the lockstep property that makes the batched golden
275//! section worth dispatching in the first place.
276//!
277//! # Units
278//!
279//! Everything here is dimensionless `f64`, for the same reason
280//! [`crate::math::parallel`] is: a generic minimiser has no single physical
281//! dimension. One lane's abscissa may be a pressure in pascals and another's a
282//! blend factor, and the objective's dimension is whatever the caller returns.
283//!
284//! `uom` typing is **not stripped** to get here — it is applied at the boundary,
285//! by the caller, exactly as the hybrid-backend epic requires: convert into the
286//! batch, convert back out. The doctest on [`golden_section_batch`] shows that
287//! boundary explicitly, maximising a `uom`-typed mass flux over a `uom`-typed
288//! pressure bracket in the shape of the steam-tables choked-flow caller.
289//!
290//! # Cargo features and portability
291//!
292//! The `rayon` paths sit behind the crate's `parallel` feature, which is **off by
293//! default**; with it off this module still compiles and every entry point still
294//! works. `rayon` is pure Rust with no system component, so everything here
295//! compiles and runs on `aarch64-linux-android` / Termux exactly as on desktop.
296//! Nothing in this module is target-gated.
297//!
298//! # Example
299//!
300//! ```rust
301//! use outram_foam_basic_lib::compute::ComputeBackend;
302//! use outram_foam_basic_lib::math::minimise::{
303//!     golden_section_batch, MinProblem, MinSettings, Sense,
304//! };
305//!
306//! // 4 independent parabolas (x - x0)^2, each bracketed on [-5, 5].
307//! let vertices = [-2.0, -0.5, 1.25, 3.0];
308//! let problems: Vec<MinProblem> = (0..4).map(|_| MinProblem::new(-5.0, 5.0)).collect();
309//!
310//! let batch = golden_section_batch(
311//!     &problems,
312//!     Sense::Minimise,
313//!     MinSettings::default(),
314//!     ComputeBackend::CpuMulti,
315//!     |i, x| (x - vertices[i]) * (x - vertices[i]),
316//! );
317//!
318//! let located = batch.extrema().expect("all four lanes converge");
319//! for (got, want) in located.iter().zip(vertices) {
320//!     // sqrt(eps)-scale accuracy is the floor for a smooth minimum.
321//!     assert!((got - want).abs() < 1e-7, "got {got}, want {want}");
322//! }
323//!
324//! // Asking for multi-CPU gives a bit-for-bit identical answer, whether or not
325//! // the `parallel` feature is compiled in.
326//! let serial = golden_section_batch(
327//!     &problems,
328//!     Sense::Minimise,
329//!     MinSettings::default(),
330//!     ComputeBackend::Serial,
331//!     |i, x| (x - vertices[i]) * (x - vertices[i]),
332//! );
333//! assert_eq!(located, serial.extrema().unwrap());
334//! ```
335
336#[cfg(feature = "parallel")]
337use rayon::prelude::*;
338
339use crate::compute::ComputeBackend;
340
341#[cfg(test)]
342mod tests;
343
344// ── Constants ────────────────────────────────────────────────────────────────
345
346/// The golden-section contraction factor `(sqrt(5) - 1) / 2 = 0.6180339...`.
347///
348/// Each iteration replaces the bracket `[a, b]` by one of `[a, d]` or `[c, b]`,
349/// both of width exactly `GOLDEN_RATIO * (b - a)`. That the two candidate
350/// sub-brackets have the *same* width is what makes the contraction rate
351/// independent of the objective, and the specific value is what makes one probe
352/// of the new bracket coincide with a probe of the old one — so each iteration
353/// after the first costs a single objective evaluation.
354///
355/// Equivalently `1 - GOLDEN_RATIO = (3 - sqrt(5)) / 2 = 0.3819660...`, which is
356/// the form bead `op-yvj.4.3` and most textbooks quote; the two are the same
357/// algorithm written from opposite ends of the bracket.
358///
359/// # Units
360///
361/// Dimensionless.
362pub const GOLDEN_RATIO: f64 = 0.618_033_988_749_894_9;
363
364/// `sqrt(f64::EPSILON)` = `1.4901161193847656e-8` — the practical accuracy floor
365/// for locating a smooth minimum by comparing function values.
366///
367/// See the module-level "Achievable accuracy" section for the derivation. This
368/// is the default [`MinSettings::x_tol_rel`], and it is a *floor*, not a
369/// guarantee: an objective flatter than quadratic at its minimum does worse, and
370/// an objective evaluated with more than rounding-level noise does worse again.
371///
372/// # Units
373///
374/// Dimensionless — it multiplies `|x|` to give an abscissa tolerance.
375pub const SQRT_EPSILON: f64 = 1.490_116_119_384_765_6e-8;
376
377/// Problem count below which a [`ComputeBackend::CpuMulti`] request runs
378/// [`golden_section_batch`] on the calling thread instead.
379///
380/// # Measured crossover
381///
382/// *Methodology.* Measured 2026-08-13 on this workspace's development machine,
383/// `std::thread::available_parallelism()` = **4**, release build, `--features
384/// parallel`, `rayon`'s global pool, otherwise-idle machine. Batches of `n`
385/// independent problems `(x - x0_i)^2` with the vertices `x0_i` spread over
386/// `[-3, 3)` so per-lane iteration counts differ, bracket `[-5, 5]`,
387/// [`MinSettings::default`], [`Sense::Minimise`]. Best of 7 samples per point,
388/// reported as wall clock for one whole batch. Produced by the `#[ignore]`d
389/// `minimise_batch_crossover_benchmark` test in `minimise/tests.rs` and
390/// transcribed from its printed output. The `cheap` objective is the two-flop
391/// parabola above; the `costly` objective adds a `ln`/`exp`/`sqrt` chain per
392/// evaluation, standing in for an equation-of-state flash. The `(repeat)`
393/// columns are the speed-ups from a second, independent run of the same
394/// benchmark, carried alongside rather than averaged away because the parallel
395/// column is far noisier than the serial one.
396///
397/// | Problems | cheap serial | cheap multi | speed-up | (repeat) | costly serial | costly multi | speed-up | (repeat) |
398/// |---|---|---|---|---|---|---|---|---|
399/// | 16 | 2.24 us | 10.06 us | 0.22x | 0.24x | 13.87 us | 12.94 us | 1.07x | 0.41x |
400/// | 32 | 4.40 us | 29.98 us | 0.15x | 0.13x | 29.80 us | 44.04 us | 0.68x | 0.74x |
401/// | 64 | 8.77 us | 31.47 us | 0.28x | 0.34x | 63.53 us | 64.86 us | 0.98x | 1.24x |
402/// | 128 | 17.96 us | 36.40 us | 0.49x | 0.86x | 140.68 us | 73.43 us | 1.92x | 1.42x |
403/// | 256 | 55.42 us | 49.41 us | 1.12x | 1.17x | 290.00 us | 186.77 us | 1.55x | 2.91x |
404/// | 512 | 148.21 us | 85.96 us | 1.72x | 3.00x | 613.95 us | 360.42 us | 1.70x | 2.94x |
405/// | 1 024 | 370.80 us | 213.96 us | 1.73x | 2.81x | 1255.20 us | 675.41 us | 1.86x | 2.82x |
406/// | 4 096 | 1542.64 us | 466.86 us | 3.30x | 3.23x | 5120.51 us | 1711.31 us | 2.99x | 2.86x |
407/// | 16 384 | 6260.28 us | 1964.94 us | 3.19x | 2.09x | 20732.41 us | 10689.59 us | 1.94x | 2.69x |
408/// | 65 536 | 28254.43 us | 8464.29 us | 3.34x | 1.55x | 84607.33 us | 32110.15 us | 2.63x | 2.03x |
409///
410/// *Result.* **256** is the smallest size at which *neither* objective lost in
411/// *either* run — cheap 1.12x and 1.17x, costly 1.55x and 2.91x — and it is the
412/// value this constant takes. At 128 the cheap objective lost both runs (0.49x,
413/// 0.86x) while the costly one won both (1.92x, 1.42x), which is the expected
414/// shape: a more expensive objective crosses over earlier because there is more
415/// work per lane to amortise the same dispatch cost. The honest headline is
416/// therefore that **the crossover depends on the caller's objective**, and this
417/// single number is set for the cheapest objective a caller is likely to pass, so
418/// that it is safe for both.
419///
420/// # Relationship to [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`]
421///
422/// That constant documents itself as an unmeasured placeholder and invites
423/// per-kernel overrides. This is that override for the batched golden section.
424///
425/// *Interpretation.* It lands on exactly the same value as
426/// [`crate::math::parallel::ROOT_BATCH_MIN_PROBLEMS`] (256), 16x below the
427/// crate-wide placeholder (4 096) and 512x below
428/// [`crate::fields::parallel::FIELD_PARALLEL_CROSSOVER`] (131 072). Two kernels
429/// agreeing is not a coincidence and is worth stating for whoever takes bead
430/// `op-yvj.4.7`: both do tens of iterations of arithmetic per lane against a
431/// handful of loaded bytes, so both are compute bound, where the field kernels do
432/// one or two flops per element loaded and are memory-bandwidth bound. The
433/// crossover tracks that distinction, not the algorithm.
434///
435/// # Limitations
436///
437/// One machine, four logical cores, one objective family, idle. Not measured on
438/// Android/Termux hardware and not on a many-core server. As with
439/// [`crate::math::parallel::ROOT_BATCH_MIN_PROBLEMS`], the crossover moves with
440/// the caller's objective cost, so a caller that knows its own objective is
441/// expensive is better off naming a [`ComputeBackend`] explicitly than trusting
442/// a single number.
443///
444/// # Units
445///
446/// A count of independent minimisation problems, dimensionless.
447pub const MINIMISE_BATCH_MIN_PROBLEMS: usize = 256;
448
449// ── Backend dispatch ─────────────────────────────────────────────────────────
450
451/// Resolve a requested backend to the one this module will actually run, given
452/// how much work there is.
453///
454/// Three reductions, in order: [`ComputeBackend::resolve`] degrades anything
455/// whose feature is off; `Gpu` degrades again because this module has no GPU
456/// kernel yet; and `CpuMulti` degrades to `Serial` below `min_work_items`. The
457/// result is only ever `Serial` or `CpuMulti`, and none of the degradations can
458/// change a returned value.
459fn effective_backend(
460    requested: ComputeBackend,
461    work_items: usize,
462    min_work_items: usize,
463) -> ComputeBackend {
464    let cpu = match requested.resolve() {
465        ComputeBackend::Gpu => ComputeBackend::CpuMulti.resolve(),
466        other => other,
467    };
468    match cpu {
469        ComputeBackend::CpuMulti if work_items >= min_work_items => ComputeBackend::CpuMulti,
470        _ => ComputeBackend::Serial,
471    }
472}
473
474/// The [`ComputeBackend`] [`golden_section_batch`] would actually use for `n`
475/// problems if asked for `requested` — without running anything.
476///
477/// Applies exactly the same reduction the kernel does (feature availability, no
478/// GPU kernel here, and the [`MINIMISE_BATCH_MIN_PROBLEMS`] size floor), so what
479/// it reports is what would run. Useful for logging and for benchmark harnesses.
480///
481/// # Arguments
482///
483/// - `requested` — the backend a caller would pass to [`golden_section_batch`].
484/// - `n` — the number of independent problems in the batch, dimensionless.
485///
486/// # Returns
487///
488/// Either [`ComputeBackend::Serial`] or [`ComputeBackend::CpuMulti`]; never
489/// [`ComputeBackend::Gpu`], because no GPU kernel exists here yet.
490///
491/// # Example
492///
493/// ```rust
494/// use outram_foam_basic_lib::compute::ComputeBackend;
495/// use outram_foam_basic_lib::math::minimise::{
496///     minimise_backend_for, MINIMISE_BATCH_MIN_PROBLEMS,
497/// };
498///
499/// // Too small to thread, whatever was asked for.
500/// assert_eq!(minimise_backend_for(ComputeBackend::CpuMulti, 8), ComputeBackend::Serial);
501///
502/// // Big enough; the answer now depends only on whether `parallel` is compiled in.
503/// let picked = minimise_backend_for(ComputeBackend::CpuMulti, MINIMISE_BATCH_MIN_PROBLEMS);
504/// assert!(picked.is_available());
505/// ```
506#[must_use]
507pub fn minimise_backend_for(requested: ComputeBackend, n: usize) -> ComputeBackend {
508    effective_backend(requested, n, MINIMISE_BATCH_MIN_PROBLEMS)
509}
510
511// ── Problem, settings, sense ─────────────────────────────────────────────────
512
513/// Whether [`golden_section_batch`] should locate a **minimum** or a **maximum**.
514///
515/// Golden section is indifferent: it compares two interior probes and keeps the
516/// sub-bracket containing the better one, and "better" is the only thing this
517/// switch changes. Nothing is negated internally, so the values a solution
518/// reports are the caller's own objective values with their own sign.
519///
520/// [`Maximise`](Self::Maximise) exists because the workspace's production
521/// golden-section caller — the homogeneous-equilibrium choked-flow solver in
522/// `tampines-steam-tables` — maximises a mass flux. A module that only minimised
523/// would force that caller to negate at the boundary, which is exactly the kind
524/// of avoidable sign bug a `uom`-typed codebase is trying to design out.
525///
526/// # Units
527///
528/// Dimensionless — a mode selector, not a quantity.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
530pub enum Sense {
531    /// Locate the abscissa where the objective is smallest. The default.
532    #[default]
533    Minimise,
534    /// Locate the abscissa where the objective is largest.
535    Maximise,
536}
537
538impl Sense {
539    /// Whether `candidate` is the better of two objective values under this sense.
540    ///
541    /// Strict comparison, so a tie resolves the same way on every run and on
542    /// every backend: the incumbent is kept. That matters more than it looks —
543    /// a plateau in the objective is common (a guard clause returning `0.0`, a
544    /// clamped property table) and a non-strict comparison would make the
545    /// surviving bracket depend on evaluation order.
546    ///
547    /// # Arguments
548    ///
549    /// - `candidate`, `incumbent` — objective values, in the objective's units.
550    ///   Both must be finite; the kernel rejects non-finite values before
551    ///   reaching here.
552    ///
553    /// # Example
554    ///
555    /// ```rust
556    /// use outram_foam_basic_lib::math::minimise::Sense;
557    ///
558    /// assert!(Sense::Minimise.is_better(1.0, 2.0));
559    /// assert!(Sense::Maximise.is_better(2.0, 1.0));
560    /// assert!(!Sense::Minimise.is_better(1.0, 1.0)); // a tie is not better
561    /// ```
562    #[inline]
563    #[must_use]
564    pub fn is_better(self, candidate: f64, incumbent: f64) -> bool {
565        match self {
566            Self::Minimise => candidate < incumbent,
567            Self::Maximise => candidate > incumbent,
568        }
569    }
570
571    /// A short human-readable label, for log lines and failure reports.
572    #[must_use]
573    pub fn label(self) -> &'static str {
574        match self {
575            Self::Minimise => "minimise",
576            Self::Maximise => "maximise",
577        }
578    }
579}
580
581/// One bracketed 1-D extremum problem: an interval believed to contain a single
582/// interior extremum of the objective.
583///
584/// # Fields and valid ranges
585///
586/// - `lo`, `hi` — the bracket. Both must be finite, or the lane reports
587///   [`MinStatus::InvalidBracket`]. They may be given in either order; the kernel
588///   normalises, exactly as [`crate::math::parallel::RootProblem`] does. A
589///   degenerate bracket (`lo == hi`) converges immediately at that point with
590///   zero iterations, which is truthful: a zero-width bracket has already met any
591///   non-negative tolerance.
592///
593/// # The precondition golden section cannot check
594///
595/// The objective must be **unimodal** on `[lo, hi]` — one interior extremum, no
596/// second basin. This is a precondition on the caller, not something the kernel
597/// verifies; see the module-level warning. Note that a *monotone* objective on
598/// the bracket is a benign special case: the contraction walks to the appropriate
599/// endpoint and converges there, which is the intended behaviour when the real
600/// extremum lies just outside. It is the *multimodal* case that is dangerous,
601/// because the answer looks exactly like a correct one.
602///
603/// There is no `guess` field, unlike [`crate::math::parallel::RootProblem`].
604/// Golden section is driven entirely by the bracket and has nowhere to put a
605/// starting iterate; a method that could use one (Brent's parabolic
606/// interpolation) is deliberately not implemented here — again, see the module
607/// docs.
608///
609/// # Units
610///
611/// Dimensionless `f64` in whatever units the caller's abscissa carries — pascals
612/// for a choke-pressure search, dimensionless for a blend factor. See the
613/// module-level "Units" section.
614///
615/// # Example
616///
617/// ```rust
618/// use outram_foam_basic_lib::math::minimise::MinProblem;
619///
620/// let p = MinProblem::new(0.0, 1.0);
621/// assert_eq!(p.width(), 1.0);
622///
623/// // Either order is accepted; the width is still positive.
624/// let q = MinProblem::new(1.0, 0.0);
625/// assert_eq!(q.width(), 1.0);
626/// ```
627#[derive(Debug, Clone, Copy, PartialEq)]
628pub struct MinProblem {
629    /// One end of the bracket. Finite; need not be less than `hi`.
630    pub lo: f64,
631    /// The other end of the bracket. Finite; need not be greater than `lo`.
632    pub hi: f64,
633}
634
635impl MinProblem {
636    /// A problem bracketed on `[lo, hi]`.
637    ///
638    /// # Arguments
639    ///
640    /// - `lo`, `hi` — the bracket ends, finite, in either order.
641    #[must_use]
642    pub fn new(lo: f64, hi: f64) -> Self {
643        Self { lo, hi }
644    }
645
646    /// The bracket width `|hi - lo|`, in the abscissa's units.
647    ///
648    /// `NaN` if either end is `NaN`; infinite if either end is infinite. Both of
649    /// those cases are rejected as [`MinStatus::InvalidBracket`] by the kernel.
650    #[must_use]
651    pub fn width(&self) -> f64 {
652        (self.hi - self.lo).abs()
653    }
654}
655
656/// Convergence tolerance on the **bracket width**, and the iteration cap.
657///
658/// A lane is converged when the bracket has shrunk to
659/// `x_tol_abs + x_tol_rel * |x_mid|`, where `x_mid` is the current bracket
660/// midpoint. There is **no function-value tolerance** — see the module-level
661/// "Convergence is on the BRACKET, never on the value" section for why one would
662/// be actively misleading here.
663///
664/// # Fields and valid ranges
665///
666/// - `x_tol_abs` — absolute abscissa tolerance, in the abscissa's own units.
667///   Must be `>= 0`. Guards the case `x* ≈ 0`, where a purely relative tolerance
668///   can never be met.
669/// - `x_tol_rel` — relative abscissa tolerance, dimensionless, `>= 0`. Values
670///   below [`SQRT_EPSILON`] are honoured but buy no accuracy on a smooth
671///   objective; they only cost iterations.
672/// - `max_iterations` — hard cap. On reaching it a lane reports
673///   [`MinStatus::MaxIterations`] and its current bracket midpoint, which
674///   [`MinSolution::extremum`] still refuses to hand out. Must be `>= 1`.
675///
676/// A negative tolerance is not rejected; it simply can never be met, and the lane
677/// will report [`MinStatus::MaxIterations`]. That is a truthful outcome rather
678/// than a silent success.
679///
680/// # Defaults, and why they differ from [`crate::math::parallel::RootSettings`]
681///
682/// `x_tol_abs = 1e-12`, `x_tol_rel = ` [`SQRT_EPSILON`] `= 1.4901161193847656e-8`,
683/// `max_iterations = 200`.
684///
685/// The relative tolerance is ~10 000x looser than the root finder's `1e-12`, and
686/// that is not a lowered standard — it is the accuracy a derivative-free
687/// minimiser can actually deliver on a smooth objective (module docs,
688/// "Achievable accuracy"). Defaulting to a far tighter tolerance would spend
689/// about **30 extra iterations** per lane narrowing a bracket whose midpoint does
690/// not improve — measured 2026-08-13: 40-47 iterations at these defaults against
691/// 77 at `x_tol_abs = 1e-15, x_tol_rel = 0` on the same 64-lane batch, for a
692/// worst abscissa error that stays at `1.05e-8` either way.
693///
694/// The iteration cap is 200 rather than 100 because golden section contracts by a
695/// fixed factor of [`GOLDEN_RATIO`] per iteration — it has no superlinear phase
696/// to bail it out. Reaching a relative `1.5e-8` from a bracket `1e14` times wider
697/// than the answer costs `ln(1e-14)/ln(0.618) ≈ 67` iterations; 200 leaves room
698/// for a caller that tightens the tolerance well past the useful floor.
699///
700/// # Example
701///
702/// ```rust
703/// use outram_foam_basic_lib::math::minimise::{MinSettings, SQRT_EPSILON};
704///
705/// // Struct-update syntax keeps the defaults you did not mean to change.
706/// let s = MinSettings {
707///     max_iterations: 500,
708///     ..MinSettings::default()
709/// };
710/// assert_eq!(s.x_tol_rel, SQRT_EPSILON);
711/// assert_eq!(s.max_iterations, 500);
712///
713/// // The steam-tables choked-flow caller's rule -- a 1 Pa bracket -- expressed
714/// // as an absolute tolerance with the relative part switched off.
715/// let one_pascal = MinSettings { x_tol_abs: 1.0, x_tol_rel: 0.0, max_iterations: 100 };
716/// assert_eq!(one_pascal.bracket_tolerance(5.0e6), 1.0);
717/// ```
718#[derive(Debug, Clone, Copy, PartialEq)]
719pub struct MinSettings {
720    /// Absolute bracket-width tolerance, in the abscissa's units. `>= 0`.
721    pub x_tol_abs: f64,
722    /// Relative bracket-width tolerance, dimensionless. `>= 0`.
723    pub x_tol_rel: f64,
724    /// Maximum iterations per lane before reporting
725    /// [`MinStatus::MaxIterations`]. `>= 1`.
726    pub max_iterations: u32,
727}
728
729impl Default for MinSettings {
730    fn default() -> Self {
731        Self {
732            x_tol_abs: 1e-12,
733            x_tol_rel: SQRT_EPSILON,
734            max_iterations: 200,
735        }
736    }
737}
738
739impl MinSettings {
740    /// The bracket-width tolerance at abscissa `x`: `x_tol_abs + x_tol_rel * |x|`.
741    ///
742    /// # Arguments
743    ///
744    /// - `x` — the abscissa at which to evaluate the tolerance, in the
745    ///   abscissa's units. The kernel passes the current bracket midpoint.
746    ///
747    /// # Returns
748    ///
749    /// A width in the abscissa's units.
750    #[inline]
751    #[must_use]
752    pub fn bracket_tolerance(&self, x: f64) -> f64 {
753        self.x_tol_abs + self.x_tol_rel * x.abs()
754    }
755}
756
757// ── Per-lane outcome ─────────────────────────────────────────────────────────
758
759/// How one lane of a batch ended.
760///
761/// Only [`Converged`](Self::Converged) means the lane located an extremum. Every
762/// other variant is a failure a caller must handle; see the module-level
763/// "Non-convergence is reported, never swallowed" section for the accessors that
764/// make it hard to skip.
765///
766/// | Variant | `last_iterate` | Meaning |
767/// |---|---|---|
768/// | [`Converged`](Self::Converged) | the located extremum | the bracket met the tolerance |
769/// | [`MaxIterations`](Self::MaxIterations) | current bracket midpoint — **not** an extremum | ran out of iterations |
770/// | [`InvalidBracket`](Self::InvalidBracket) | `NaN` | a bracket end is not finite |
771/// | [`NotFinite`](Self::NotFinite) | the abscissa where it happened | the objective evaluated to `NaN`/infinity |
772///
773/// There is deliberately **no** analogue of
774/// [`crate::math::parallel::RootStatus::NotBracketed`]. A root finder can check
775/// its precondition — a sign change across the bracket is one comparison — and
776/// refuse when it fails. A minimiser's precondition is unimodality, which cannot
777/// be checked from a finite number of evaluations. Adding a status that is never
778/// returned would imply a guarantee this module does not provide.
779///
780/// # Units
781///
782/// Dimensionless — a status tag.
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
784pub enum MinStatus {
785    /// The bracket shrank to the requested tolerance. Note that this says the
786    /// bracket converged, **not** that the objective is unimodal — on a
787    /// multimodal bracket a lane converges happily to a local extremum.
788    Converged,
789    /// `max_iterations` was reached with the bracket still wider than the
790    /// tolerance. The reported iterate is the current bracket midpoint and is
791    /// **not** claimed to be an extremum.
792    MaxIterations,
793    /// A bracket end was infinite or `NaN`, so there is no interval to contract.
794    InvalidBracket,
795    /// The objective evaluated to a non-finite number. Iteration stops
796    /// immediately, because continuing would propagate the `NaN` into a
797    /// plausible-looking answer — and unlike a root finder there is no sign
798    /// structure left to recover from.
799    NotFinite,
800}
801
802impl MinStatus {
803    /// Whether this status means the lane located an extremum.
804    ///
805    /// # Example
806    ///
807    /// ```rust
808    /// use outram_foam_basic_lib::math::minimise::MinStatus;
809    ///
810    /// assert!(MinStatus::Converged.is_converged());
811    /// assert!(!MinStatus::MaxIterations.is_converged());
812    /// ```
813    #[must_use]
814    pub fn is_converged(self) -> bool {
815        matches!(self, Self::Converged)
816    }
817
818    /// A short human-readable label, for log lines and failure reports.
819    #[must_use]
820    pub fn label(self) -> &'static str {
821        match self {
822            Self::Converged => "converged",
823            Self::MaxIterations => "max-iterations",
824            Self::InvalidBracket => "invalid-bracket",
825            Self::NotFinite => "not-finite",
826        }
827    }
828}
829
830/// The outcome of a single lane: its status, its located abscissa, the objective
831/// value there, the final bracket width and the work it took.
832///
833/// The fields are private on purpose. The only way to get a number claimed to be
834/// an extremum is [`Self::extremum`], which returns `Option<f64>` and hands back
835/// `Some` only for a converged lane. The raw iterate is available from
836/// [`Self::last_iterate`], whose name is chosen so that using it as an answer is
837/// a visible decision in the calling code rather than an accident.
838///
839/// **Abscissa versus value.** [`Self::extremum`] is the *argument* `x*` — the
840/// argmin (or argmax). [`Self::extremal_value`] is the *objective value* `f(x*)`
841/// there. Mixing those two up is the classic minimisation bug, so they are named
842/// apart rather than both being called "the minimum".
843///
844/// `Copy`, so it can be read out of a [`MinBatch`] without cloning.
845///
846/// # Units
847///
848/// [`Self::extremum`], [`Self::last_iterate`] and [`Self::bracket_width`] are in
849/// the abscissa's units; [`Self::extremal_value`] and [`Self::last_value`] are in
850/// the objective's units; [`Self::iterations`] is a dimensionless count.
851#[derive(Debug, Clone, Copy, PartialEq)]
852pub struct MinSolution {
853    x: f64,
854    f_x: f64,
855    bracket_width: f64,
856    iterations: u32,
857    status: MinStatus,
858}
859
860impl MinSolution {
861    /// The located extremum's **abscissa** `x*`, if this lane converged.
862    ///
863    /// `None` for every failure status. This is the accessor to reach for; the
864    /// `Option` is the point.
865    ///
866    /// # Returns
867    ///
868    /// `Some(x)` in the abscissa's units when [`Self::status`] is
869    /// [`MinStatus::Converged`], otherwise `None`. The value returned is the
870    /// midpoint of the final bracket, so the worst-case distance to the true
871    /// extremum of a unimodal objective is half of [`Self::bracket_width`] — but
872    /// read the module-level accuracy section before believing a bracket width
873    /// below `sqrt(eps) * |x|`.
874    #[must_use]
875    pub fn extremum(&self) -> Option<f64> {
876        if self.status.is_converged() {
877            Some(self.x)
878        } else {
879            None
880        }
881    }
882
883    /// The objective **value** `f(x*)` at the located extremum, if this lane
884    /// converged.
885    ///
886    /// `None` for every failure status. In the objective's units, with the
887    /// caller's own sign — nothing is negated for [`Sense::Maximise`].
888    #[must_use]
889    pub fn extremal_value(&self) -> Option<f64> {
890        if self.status.is_converged() {
891            Some(self.f_x)
892        } else {
893            None
894        }
895    }
896
897    /// The last abscissa the solver held, converged or not.
898    ///
899    /// **An extremum only when [`Self::status`] is [`MinStatus::Converged`].**
900    /// For [`MinStatus::MaxIterations`] this is the midpoint of the bracket the
901    /// lane ran out of iterations on, offered for diagnosis — for deciding
902    /// whether the lane was close or wildly off — not as an answer. For
903    /// [`MinStatus::InvalidBracket`] it is `NaN`, because there is no honest
904    /// number to report.
905    #[must_use]
906    pub fn last_iterate(&self) -> f64 {
907        self.x
908    }
909
910    /// The objective value at [`Self::last_iterate`], in the objective's units.
911    ///
912    /// `NaN` when no meaningful evaluation was reached; for
913    /// [`MinStatus::NotFinite`] it is the offending non-finite value itself,
914    /// which tells you whether the objective overflowed or produced a `NaN`.
915    #[must_use]
916    pub fn last_value(&self) -> f64 {
917        self.f_x
918    }
919
920    /// Width of the final bracket, in the abscissa's units.
921    ///
922    /// `NaN` when the lane never established a bracket. For a converged lane this
923    /// bounds the abscissa error at half its value **for a unimodal objective**;
924    /// it says nothing at all if the objective was multimodal.
925    #[must_use]
926    pub fn bracket_width(&self) -> f64 {
927        self.bracket_width
928    }
929
930    /// Iterations performed on this lane, dimensionless.
931    ///
932    /// One iteration contracts the bracket by exactly [`GOLDEN_RATIO`] and costs
933    /// **one** objective evaluation, on top of the two interior probes every lane
934    /// pays up front and the one final evaluation at the returned midpoint. So a
935    /// lane reporting `k` iterations called the objective `k + 3` times, unless
936    /// it stopped early on a non-finite value.
937    #[must_use]
938    pub fn iterations(&self) -> u32 {
939        self.iterations
940    }
941
942    /// How this lane ended.
943    #[must_use]
944    pub fn status(&self) -> MinStatus {
945        self.status
946    }
947
948    /// Whether this lane located an extremum.
949    #[must_use]
950    pub fn converged(&self) -> bool {
951        self.status.is_converged()
952    }
953
954    /// A converged lane.
955    #[inline]
956    fn converged_at(x: f64, f_x: f64, bracket_width: f64, iterations: u32) -> Self {
957        Self {
958            x,
959            f_x,
960            bracket_width,
961            iterations,
962            status: MinStatus::Converged,
963        }
964    }
965
966    /// A failed lane, carrying whatever diagnostic information exists.
967    #[inline]
968    fn failed(status: MinStatus, x: f64, f_x: f64, bracket_width: f64, iterations: u32) -> Self {
969        Self {
970            x,
971            f_x,
972            bracket_width,
973            iterations,
974            status,
975        }
976    }
977}
978
979/// A batch of `N` lane outcomes, in the same order as the problems handed in.
980///
981/// Lane `i` of the result corresponds to `problems[i]`, always — the parallel
982/// path preserves order, so no index bookkeeping is needed.
983///
984/// # Getting numbers out
985///
986/// - [`Self::extrema`] — all-or-nothing abscissae. `Ok(Vec<f64>)` only when every
987///   lane converged; otherwise `Err(`[`MinBatchFailure`]`)`.
988/// - [`Self::extremal_values`] — the same, for the objective values.
989/// - [`Self::solutions`] — per-lane, when the caller wants to handle failures
990///   individually (widen the bracket, flag the cell, fall back to a scan).
991///
992/// # Units
993///
994/// See [`MinSolution`].
995#[derive(Debug, Clone, PartialEq)]
996pub struct MinBatch {
997    solutions: Vec<MinSolution>,
998}
999
1000impl MinBatch {
1001    /// Every lane's outcome, in problem order.
1002    #[must_use]
1003    pub fn solutions(&self) -> &[MinSolution] {
1004        &self.solutions
1005    }
1006
1007    /// Consume the batch and take the outcomes.
1008    #[must_use]
1009    pub fn into_solutions(self) -> Vec<MinSolution> {
1010        self.solutions
1011    }
1012
1013    /// Number of lanes, dimensionless.
1014    #[must_use]
1015    pub fn len(&self) -> usize {
1016        self.solutions.len()
1017    }
1018
1019    /// Whether the batch has no lanes.
1020    #[must_use]
1021    pub fn is_empty(&self) -> bool {
1022        self.solutions.is_empty()
1023    }
1024
1025    /// Lane `i`'s outcome, or `None` if `i` is out of range.
1026    #[must_use]
1027    pub fn get(&self, i: usize) -> Option<MinSolution> {
1028        self.solutions.get(i).copied()
1029    }
1030
1031    /// Whether every lane converged. Vacuously `true` for an empty batch.
1032    #[must_use]
1033    pub fn all_converged(&self) -> bool {
1034        self.solutions.iter().all(MinSolution::converged)
1035    }
1036
1037    /// How many lanes failed to converge, dimensionless.
1038    #[must_use]
1039    pub fn failure_count(&self) -> usize {
1040        self.solutions.iter().filter(|s| !s.converged()).count()
1041    }
1042
1043    /// The first failing lane and its outcome, if any.
1044    ///
1045    /// The natural thing to print when a batch fails: it names the lane to look
1046    /// at rather than just the count.
1047    #[must_use]
1048    pub fn first_failure(&self) -> Option<(usize, MinSolution)> {
1049        self.solutions
1050            .iter()
1051            .enumerate()
1052            .find(|(_, s)| !s.converged())
1053            .map(|(i, s)| (i, *s))
1054    }
1055
1056    /// Every failing lane, as `(index, outcome)` pairs.
1057    ///
1058    /// Allocates, so prefer [`Self::first_failure`] or [`Self::failure_count`] on
1059    /// a hot path.
1060    #[must_use]
1061    pub fn failures(&self) -> Vec<(usize, MinSolution)> {
1062        self.solutions
1063            .iter()
1064            .enumerate()
1065            .filter(|(_, s)| !s.converged())
1066            .map(|(i, s)| (i, *s))
1067            .collect()
1068    }
1069
1070    /// The located **abscissae**, or an error naming the failures — the
1071    /// all-or-nothing path.
1072    ///
1073    /// # Returns
1074    ///
1075    /// `Ok(v)` with `v[i]` the extremum of problem `i`, in the abscissa's units,
1076    /// when every lane converged. Otherwise `Err(`[`MinBatchFailure`]`)` carrying
1077    /// the failure count and the first failing lane. **No `Vec` of
1078    /// plausible-looking numbers is ever returned for a batch that contained a
1079    /// failure.**
1080    ///
1081    /// An empty batch returns `Ok(vec![])`.
1082    ///
1083    /// # Errors
1084    ///
1085    /// [`MinBatchFailure`] when one or more lanes did not converge.
1086    ///
1087    /// # Example
1088    ///
1089    /// ```rust
1090    /// use outram_foam_basic_lib::compute::ComputeBackend;
1091    /// use outram_foam_basic_lib::math::minimise::{
1092    ///     golden_section_batch, MinProblem, MinSettings, MinStatus, Sense,
1093    /// };
1094    ///
1095    /// // Lane 1's objective is non-finite on its bracket.
1096    /// let problems = [MinProblem::new(-1.0, 1.0), MinProblem::new(-1.0, 1.0)];
1097    /// let batch = golden_section_batch(
1098    ///     &problems,
1099    ///     Sense::Minimise,
1100    ///     MinSettings::default(),
1101    ///     ComputeBackend::Serial,
1102    ///     |i, x| if i == 1 { f64::NAN } else { x * x },
1103    /// );
1104    ///
1105    /// let err = batch.extrema().expect_err("lane 1 is not finite");
1106    /// assert_eq!(err.failure_count, 1);
1107    /// assert_eq!(err.first_index, 1);
1108    /// assert_eq!(err.first_status, MinStatus::NotFinite);
1109    ///
1110    /// // The converged lane is still individually readable.
1111    /// assert!(batch.solutions()[0].extremum().is_some());
1112    /// assert!(batch.solutions()[1].extremum().is_none());
1113    /// ```
1114    pub fn extrema(&self) -> Result<Vec<f64>, MinBatchFailure> {
1115        self.check_all_converged()?;
1116        Ok(self.solutions.iter().map(|s| s.x).collect())
1117    }
1118
1119    /// The objective **values** at the located extrema, or an error naming the
1120    /// failures.
1121    ///
1122    /// The value counterpart of [`Self::extrema`], with identical failure
1123    /// behaviour. In the objective's units, with the caller's own sign.
1124    ///
1125    /// # Errors
1126    ///
1127    /// [`MinBatchFailure`] when one or more lanes did not converge.
1128    pub fn extremal_values(&self) -> Result<Vec<f64>, MinBatchFailure> {
1129        self.check_all_converged()?;
1130        Ok(self.solutions.iter().map(|s| s.f_x).collect())
1131    }
1132
1133    /// `Err` describing the first failure, if any lane failed.
1134    fn check_all_converged(&self) -> Result<(), MinBatchFailure> {
1135        if let Some((i, s)) = self.first_failure() {
1136            return Err(MinBatchFailure {
1137                total: self.solutions.len(),
1138                failure_count: self.failure_count(),
1139                first_index: i,
1140                first_status: s.status(),
1141                first_iterations: s.iterations(),
1142            });
1143        }
1144        Ok(())
1145    }
1146}
1147
1148/// One or more lanes of a [`MinBatch`] did not converge.
1149///
1150/// Returned by [`MinBatch::extrema`] and [`MinBatch::extremal_values`]. It names
1151/// both the scale of the problem (how many of how many) and a specific lane to
1152/// look at, because "3 of 10 000 lanes failed" is only actionable once you know
1153/// *which* lane.
1154///
1155/// # Units
1156///
1157/// All counts and indices are dimensionless.
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1159#[error(
1160    "{failure_count} of {total} minimisation problems did not converge; \
1161     first failure at lane {first_index} with status {first_status:?} \
1162     after {first_iterations} iterations"
1163)]
1164pub struct MinBatchFailure {
1165    /// Number of lanes in the batch.
1166    pub total: usize,
1167    /// Number of lanes that did not converge.
1168    pub failure_count: usize,
1169    /// Index of the first non-converged lane.
1170    pub first_index: usize,
1171    /// Why that lane failed.
1172    pub first_status: MinStatus,
1173    /// Iterations that lane performed before giving up.
1174    pub first_iterations: u32,
1175}
1176
1177// ── The public batch entry point ─────────────────────────────────────────────
1178
1179/// Locate `N` independent 1-D extrema by golden-section search, on the chosen
1180/// backend.
1181///
1182/// This is the entry point for derivative-free bracketed extremum search: an
1183/// objective you can evaluate on a bracket you believe contains one interior
1184/// extremum, for many independent lanes at once. It is the right tool when a
1185/// root find is the wrong one — minimising a residual, picking a limiter blend
1186/// factor, or locating a property-inversion objective's turning point where that
1187/// objective is not monotone.
1188///
1189/// # Arguments
1190///
1191/// - `problems` — one [`MinProblem`] per lane. `problems[i]`'s bracket is used
1192///   with `f(i, ·)`.
1193/// - `sense` — [`Sense::Minimise`] or [`Sense::Maximise`]; nothing is negated
1194///   internally, so reported values keep the caller's sign.
1195/// - `settings` — bracket tolerance and iteration cap; see [`MinSettings`].
1196/// - `backend` — requested execution backend. What actually runs is
1197///   [`minimise_backend_for`] applied to it: an unavailable backend degrades,
1198///   `Gpu` degrades (no GPU kernel here yet), and a batch below
1199///   [`MINIMISE_BATCH_MIN_PROBLEMS`] runs serially. **None of those changes the
1200///   answer.**
1201/// - `f` — the objective. `f(i, x)` must return the value of lane `i`'s function
1202///   at abscissa `x`. It **must be a pure deterministic function of its
1203///   arguments** — see the module-level "Determinism" section for what breaks if
1204///   it is not. It is called from multiple threads on the `CpuMulti` path, hence
1205///   the `Sync` bound; the bound is present in both feature builds so that
1206///   enabling `parallel` never changes a public signature.
1207///
1208/// # Returns
1209///
1210/// A [`MinBatch`] with one [`MinSolution`] per problem, in problem order. An
1211/// empty `problems` slice returns an empty batch and calls `f` zero times.
1212///
1213/// # Cost
1214///
1215/// Two objective evaluations up front, one per iteration, and one final
1216/// evaluation at the returned bracket midpoint. The iteration count is fixed by
1217/// the geometry alone — `ceil(ln(tol/W0) / ln(GOLDEN_RATIO))` — and does **not**
1218/// depend on the objective's shape, which is what makes this kernel's per-lane
1219/// work uniform and its batched form the cleanest fit for lockstep execution in
1220/// the whole hybrid-backend epic.
1221///
1222/// # Preconditions the kernel cannot check
1223///
1224/// The objective must be unimodal on each bracket. It is not verified and cannot
1225/// be; a multimodal bracket yields *a* local extremum with `Converged` status.
1226/// See the module-level warning — this is the failure mode to worry about.
1227///
1228/// # Determinism
1229///
1230/// Bit-for-bit identical on [`ComputeBackend::Serial`] and
1231/// [`ComputeBackend::CpuMulti`], at any thread count, for a pure `f`. Both
1232/// backends run the same per-lane kernel and no arithmetic crosses lanes.
1233///
1234/// # Non-convergence
1235///
1236/// Per lane, never swallowed. A lane whose bracket end is non-finite reports
1237/// [`MinStatus::InvalidBracket`] and a `NaN` iterate; a lane whose objective
1238/// returns a non-finite value reports [`MinStatus::NotFinite`] at the offending
1239/// abscissa; a lane that exhausts `max_iterations` reports
1240/// [`MinStatus::MaxIterations`] and its bracket midpoint, which
1241/// [`MinSolution::extremum`] still refuses to hand out. [`MinBatch::extrema`]
1242/// turns any failure into an `Err`.
1243///
1244/// # Verification
1245///
1246/// *Methodology.* Checked against extrema known in closed form, so the oracle is
1247/// exact rather than another implementation. Four families, all on 64 lanes
1248/// (except where noted) with vertices spread over `[-3, 3)` and bracket `[-5, 5]`:
1249///
1250/// 1. **Quadratic** `f(x) = 1 + (x - x0)^2` at [`MinSettings::default`]. Exact
1251///    minimiser `x0`, minimum value `1`. The realistic reference case — an
1252///    order-unity value at the extremum, as a physical objective has.
1253/// 2. **Deliberately flat** `f(x) = 1 + (x - x0)^4`, with the tolerance driven
1254///    *below* every arithmetic floor so the arithmetic binds rather than the
1255///    stopping rule. Same exact minimiser; quartically flat there.
1256/// 3. **Transcendental** `f(x) = x ln x` on `[0.05, 3]`, one lane, whose
1257///    minimiser is `1/e = 0.36787944117144233` and minimum value `-1/e`, both
1258///    exactly.
1259/// 4. **Maximisation** `f(x) = sin x` on `[0, pi]`, one lane, maximiser `pi/2`
1260///    and maximum `1`, run under [`Sense::Maximise`] so the sense switch is
1261///    checked against an analytic answer rather than against the minimisation
1262///    path — a mirror test would pass even if both senses were wrong in the same
1263///    way.
1264///
1265/// *Pass criterion.* `status == Converged` on every lane, and
1266/// `|x_located - x_analytic| <= 1e-6` for families 1, 3 and 4 (two orders of
1267/// margin over the `sqrt(eps) ≈ 1.5e-8` floor), plus `|f_located - f_analytic| <=
1268/// 1e-12` for families 3 and 4. Family 2's criterion is deliberately `1e-2`,
1269/// because `eps^(1/4) ≈ 1.2e-4` is the floor there and asserting `1e-6` would be
1270/// asserting something false.
1271///
1272/// *Results, measured 2026-08-13 by `golden_section_matches_analytic_minima`,
1273/// `flat_minimum_exposes_the_sqrt_eps_limit`,
1274/// `golden_section_matches_analytic_transcendental_minimum` and
1275/// `maximise_matches_analytic_sine_peak` in `minimise/tests.rs`, release build,
1276/// 4 logical cores:*
1277///
1278/// | Family | worst `\|x - x_analytic\|` | worst value error | iterations |
1279/// |---|---|---|---|
1280/// | 1. `1 + (x - x0)^2`, default tol | **1.151892e-8** | **2.220446e-16** | 40-47 |
1281/// | 2. `1 + (x - x0)^4`, tol below the floor | **1.026485e-4** | — | 77 |
1282/// | 3. `x ln x` | **6.967008e-10** | **5.551115e-17** | 42 |
1283/// | 4. `sin x`, [`Sense::Maximise`] | **0.000000e0** | **0.000000e0** | 39 |
1284///
1285/// *Interpretation.* Family 1 lands at 0.77x the `sqrt(eps)` floor — the method
1286/// delivers exactly the accuracy the arithmetic allows and no more, which is what
1287/// it should do given the default `x_tol_rel` *is* that floor. Its value error is
1288/// one ULP at `f = 1`, illustrating the module's point about why a value-based
1289/// convergence test would be wrong: the value is already correct to the last bit
1290/// while the abscissa is still wrong in its 9th digit. Family 2 is **9 742x
1291/// worse** in the abscissa than family 1 on identical lanes, which is the flat-
1292/// minimum penalty measured rather than asserted. Family 3 confirms the same
1293/// behaviour on a transcendental objective, ruling out an artefact of polynomial
1294/// test functions. Family 4 recovers `pi/2` and `1.0` exactly, so
1295/// [`Sense::Maximise`] is not merely self-consistent but correct, and the value
1296/// is returned with the caller's own positive sign — nothing is negated
1297/// internally.
1298///
1299/// The iteration counts in family 1 vary (40-47) only because
1300/// [`MinSettings::default`] scales its tolerance with `|x_mid|`; under a purely
1301/// absolute tolerance the count is identical on every lane, since golden
1302/// section's contraction is fixed by geometry and not by the objective. That
1303/// uniformity is verified separately by `bracket_contracts_at_the_golden_ratio`.
1304///
1305/// # Example — a batch of parabolas
1306///
1307/// ```rust
1308/// use outram_foam_basic_lib::compute::ComputeBackend;
1309/// use outram_foam_basic_lib::math::minimise::{
1310///     golden_section_batch, MinProblem, MinSettings, Sense,
1311/// };
1312///
1313/// // Minimise (x - 2)^2 + 1 on [-10, 10]: the vertex is at x = 2, value 1.
1314/// let problems = [MinProblem::new(-10.0, 10.0)];
1315/// let batch = golden_section_batch(
1316///     &problems,
1317///     Sense::Minimise,
1318///     MinSettings::default(),
1319///     ComputeBackend::Serial,
1320///     |_, x| (x - 2.0) * (x - 2.0) + 1.0,
1321/// );
1322///
1323/// let s = batch.solutions()[0];
1324/// assert!(s.converged());
1325/// // The abscissa and the value are separate accessors, on purpose.
1326/// assert!((s.extremum().unwrap() - 2.0).abs() < 1e-6);
1327/// assert!((s.extremal_value().unwrap() - 1.0).abs() < 1e-12);
1328/// ```
1329///
1330/// # Example — maximisation at the `uom` boundary
1331///
1332/// The batch is dimensionless and the caller converts at its edge. This lane has
1333/// the shape of the workspace's production golden-section caller: maximise a
1334/// mass flux over a pressure bracket, keeping `uom` typing on both sides of the
1335/// call.
1336///
1337/// ```rust
1338/// use outram_foam_basic_lib::compute::ComputeBackend;
1339/// use outram_foam_basic_lib::math::minimise::{
1340///     golden_section_batch, MinProblem, MinSettings, Sense,
1341/// };
1342/// use uom::si::f64::{MassFlux, Pressure};
1343/// use uom::si::mass_flux::kilogram_per_square_meter_second;
1344/// use uom::si::pressure::pascal;
1345///
1346/// // A stand-in for G(p) along an isentrope: a single interior peak at 2 MPa.
1347/// // (Analytic, not thermodynamic -- the real one is an IF97 flash.)
1348/// let g_of_p = |p: Pressure| -> MassFlux {
1349///     let x = p.get::<pascal>() / 1.0e6;
1350///     MassFlux::new::<kilogram_per_square_meter_second>(1.0e4 * (1.0 - (x - 2.0) * (x - 2.0) / 9.0))
1351/// };
1352///
1353/// // Convert in: the bracket in pascals, as plain f64.
1354/// let lo = Pressure::new::<pascal>(0.1e6);
1355/// let hi = Pressure::new::<pascal>(5.0e6);
1356/// let problems = [MinProblem::new(lo.get::<pascal>(), hi.get::<pascal>())];
1357///
1358/// let batch = golden_section_batch(
1359///     &problems,
1360///     Sense::Maximise,
1361///     MinSettings { x_tol_abs: 1.0, x_tol_rel: 0.0, max_iterations: 100 },
1362///     ComputeBackend::Serial,
1363///     |_, p_pa| g_of_p(Pressure::new::<pascal>(p_pa)).get::<kilogram_per_square_meter_second>(),
1364/// );
1365///
1366/// // Convert out: back to a typed pressure and mass flux.
1367/// let s = batch.solutions()[0];
1368/// let p_star = Pressure::new::<pascal>(s.extremum().expect("converged"));
1369/// let g_star = MassFlux::new::<kilogram_per_square_meter_second>(
1370///     s.extremal_value().expect("converged"),
1371/// );
1372///
1373/// assert!((p_star.get::<pascal>() - 2.0e6).abs() <= 1.0); // 1 Pa bracket
1374/// assert!((g_star.get::<kilogram_per_square_meter_second>() - 1.0e4).abs() < 1.0e-3);
1375/// ```
1376#[must_use]
1377pub fn golden_section_batch<F>(
1378    problems: &[MinProblem],
1379    sense: Sense,
1380    settings: MinSettings,
1381    backend: ComputeBackend,
1382    f: F,
1383) -> MinBatch
1384where
1385    F: Fn(usize, f64) -> f64 + Sync,
1386{
1387    golden_section_batch_min(
1388        problems,
1389        sense,
1390        settings,
1391        backend,
1392        MINIMISE_BATCH_MIN_PROBLEMS,
1393        f,
1394    )
1395}
1396
1397/// [`golden_section_batch`] with the size floor supplied by the caller.
1398///
1399/// Exists so the crossover benchmark can measure the multi-CPU path *below*
1400/// [`MINIMISE_BATCH_MIN_PROBLEMS`] — the only way to find where the crossover
1401/// actually is — and so the cross-backend bitwise tests are not vacuous on small
1402/// batches. Not public: production callers get the measured floor.
1403pub(crate) fn golden_section_batch_min<F>(
1404    problems: &[MinProblem],
1405    sense: Sense,
1406    settings: MinSettings,
1407    backend: ComputeBackend,
1408    min_problems: usize,
1409    f: F,
1410) -> MinBatch
1411where
1412    F: Fn(usize, f64) -> f64 + Sync,
1413{
1414    let n = problems.len();
1415    let solutions: Vec<MinSolution> = match effective_backend(backend, n, min_problems) {
1416        #[cfg(feature = "parallel")]
1417        ComputeBackend::CpuMulti => problems
1418            .par_iter()
1419            .enumerate()
1420            // No `min_len` floor: per-lane iteration counts vary with the ratio
1421            // of bracket width to tolerance, so the splitter is left free to
1422            // steal down to a single lane.
1423            .map(|(i, p)| golden_section_one(i, *p, sense, settings, &f))
1424            .collect(),
1425        _ => problems
1426            .iter()
1427            .enumerate()
1428            .map(|(i, p)| golden_section_one(i, *p, sense, settings, &f))
1429            .collect(),
1430    };
1431    MinBatch { solutions }
1432}
1433
1434// ── Per-lane kernel — one implementation, both backends ──────────────────────
1435
1436/// The single-lane golden-section contraction that **both** backends run.
1437///
1438/// `#[inline]` so the serial loop and the `rayon` map compile to the same inner
1439/// code — part of why the two backends agree bit for bit.
1440///
1441/// The structure is the one taken from `tampines-steam-tables`'
1442/// `golden_section_max_g` (see the module docs for the citation), with the
1443/// retained probe reused rather than recomputed:
1444///
1445/// ```text
1446/// c = b - gr*(b - a);  d = a + gr*(b - a)
1447/// loop:
1448///     if (b - a) <= tol: done, answer is (a + b)/2
1449///     if f(c) is better than f(d):  b <- d;  d <- c;  c <- b - gr*(b - a)  [1 eval]
1450///     else:                         a <- c;  c <- d;  d <- a + gr*(b - a)  [1 eval]
1451/// ```
1452#[inline]
1453fn golden_section_one<F>(
1454    i: usize,
1455    p: MinProblem,
1456    sense: Sense,
1457    settings: MinSettings,
1458    f: &F,
1459) -> MinSolution
1460where
1461    F: Fn(usize, f64) -> f64,
1462{
1463    let nan = f64::NAN;
1464    if !p.lo.is_finite() || !p.hi.is_finite() {
1465        return MinSolution::failed(MinStatus::InvalidBracket, nan, nan, nan, 0);
1466    }
1467
1468    // Normalise the bracket; either order is accepted, as for `RootProblem`.
1469    let (mut a, mut b) = if p.lo <= p.hi {
1470        (p.lo, p.hi)
1471    } else {
1472        (p.hi, p.lo)
1473    };
1474
1475    // The two interior probes, placed so that one survives each contraction.
1476    let mut c = b - GOLDEN_RATIO * (b - a);
1477    let mut d = a + GOLDEN_RATIO * (b - a);
1478    let mut f_c = f(i, c);
1479    if !f_c.is_finite() {
1480        return MinSolution::failed(MinStatus::NotFinite, c, f_c, b - a, 0);
1481    }
1482    let mut f_d = f(i, d);
1483    if !f_d.is_finite() {
1484        return MinSolution::failed(MinStatus::NotFinite, d, f_d, b - a, 0);
1485    }
1486
1487    for iteration in 1..=settings.max_iterations {
1488        let width = b - a;
1489        let mid = a + 0.5 * width;
1490        if width <= settings.bracket_tolerance(mid) {
1491            let f_mid = f(i, mid);
1492            if !f_mid.is_finite() {
1493                return MinSolution::failed(MinStatus::NotFinite, mid, f_mid, width, iteration - 1);
1494            }
1495            return MinSolution::converged_at(mid, f_mid, width, iteration - 1);
1496        }
1497
1498        if sense.is_better(f_c, f_d) {
1499            // The extremum lies in [a, d]: keep `c` as the new right-hand probe.
1500            b = d;
1501            d = c;
1502            f_d = f_c;
1503            c = b - GOLDEN_RATIO * (b - a);
1504            f_c = f(i, c);
1505            if !f_c.is_finite() {
1506                return MinSolution::failed(MinStatus::NotFinite, c, f_c, b - a, iteration);
1507            }
1508        } else {
1509            // The extremum lies in [c, b]: keep `d` as the new left-hand probe.
1510            a = c;
1511            c = d;
1512            f_c = f_d;
1513            d = a + GOLDEN_RATIO * (b - a);
1514            f_d = f(i, d);
1515            if !f_d.is_finite() {
1516                return MinSolution::failed(MinStatus::NotFinite, d, f_d, b - a, iteration);
1517            }
1518        }
1519    }
1520
1521    let width = b - a;
1522    let mid = a + 0.5 * width;
1523    MinSolution::failed(
1524        MinStatus::MaxIterations,
1525        mid,
1526        f(i, mid),
1527        width,
1528        settings.max_iterations,
1529    )
1530}