bobyqa 0.1.0

Dependency-free BOBYQA: derivative-free minimization of a box-constrained function from values alone — a faithful pure-Rust port of PRIMA.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
//! A pure-Rust, dependency-free port of M. J. D. Powell's BOBYQA (Bound
//! Optimization BY Quadratic Approximation) — a derivative-free,
//! box-constrained local optimizer, ported from PRIMA's modern Fortran.
//!
//! Trajectory-parity-tested **bit-exact** against PRIMA (natively and on
//! `wasm32-wasip1`).
//!
//! Three invariants hold across every call:
//! - **Feasibility** — every point at which the objective is evaluated lies
//!   within `[lower, upper]`.
//! - **Determinism** — no global mutable state, no RNG, no I/O, no threads;
//!   identical inputs give identical outputs on a given target.
//! - **Zero-alloc warm path** — [`Bobyqa::new`] is the sole heap-allocation
//!   site; a built [`Bobyqa`] then runs [`Bobyqa::minimize`] with no further
//!   allocation.

#![forbid(unsafe_code)]

mod bobyqb;
mod consts;
mod geometry;
mod initialize;
mod linalg;
mod mat;
mod math;
#[cfg(not(feature = "count-kernels"))]
mod powalg;
#[cfg(feature = "count-kernels")]
pub mod powalg; // F0a: counter must be reachable from tests/kernel_counts.rs under this feature only
mod rescue;
#[cfg(test)]
mod test_support;
mod trustregion;
mod update;
mod util;

use consts::{
    BOUNDMAX, ETA1_DFT, ETA2_DFT, GAMMA1_DFT, GAMMA2_DFT, MAXFUN_DIM_DFT, RHOBEG_DFT, RHOEND_DFT,
};
use util::moderatex1;

/// Tuning knobs for [`Bobyqa`]. No `Default`: `npt`'s default (`2n + 1`) needs `n` —
/// use [`Config::new`] and struct-update syntax for overrides.
#[derive(Debug, Clone, Copy)]
pub struct Config {
    /// Number of interpolation points, in `n + 2 ..= (n + 1)(n + 2) / 2` (default `2n + 1`,
    /// Powell's recommendation). The `npt` initial model-building evaluations are a per-call
    /// floor on the evaluation count: hot-loop callers re-solving fresh objectives may prefer
    /// the minimum `n + 2` to trade model richness for fewer evaluations.
    pub npt: usize,
    /// Initial trust-region radius — positive and finite (default 1.0).
    pub rho_begin: f64,
    /// Final trust-region radius — the target accuracy, in `(0, rho_begin]` (default 1e-6).
    pub rho_end: f64,
    /// Objective-evaluation budget — must exceed `npt` (default `500 * n`).
    pub max_fun: usize,
    /// Stop as soon as an evaluation reaches `f <= f_target` (default `-inf`: disabled).
    /// NaN is rejected by [`Bobyqa::new`].
    pub f_target: f64,
}

impl Config {
    /// PRIMA's defaults for an `n`-dimensional problem.
    #[must_use]
    pub fn new(n: usize) -> Self {
        Self {
            npt: 2 * n + 1,
            rho_begin: RHOBEG_DFT,
            rho_end: RHOEND_DFT,
            max_fun: MAXFUN_DIM_DFT * n,
            // PRIMA's FTARGET_DFT is -REALMAX, which would terminate on f = -REALMAX;
            // -inf is strictly "off" (design §4.2).
            f_target: f64::NEG_INFINITY,
        }
    }
}

/// Why the solver stopped (or why construction failed).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
    /// The trust-region radius reached `rho_end`.
    Converged,
    /// An evaluation reached `f_target`.
    TargetReached,
    /// The `max_fun` evaluation budget was exhausted.
    MaxFunReached,
    /// The rescue procedure could not restore the interpolation geometry.
    ModelDegenerate,
    /// Bad bounds, `npt`, or slice sizes.
    InvalidArgs,
}

impl core::fmt::Display for Status {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(match self {
            Status::Converged => "the trust-region radius reached rho_end",
            Status::TargetReached => "an evaluation reached f_target",
            Status::MaxFunReached => "the max_fun evaluation budget was exhausted",
            Status::ModelDegenerate => "the interpolation model degenerated beyond rescue",
            Status::InvalidArgs => "invalid arguments: bad bounds, npt, sizes, or config",
        })
    }
}

impl std::error::Error for Status {}

/// The result of one [`Bobyqa::minimize`] call.
#[derive(Debug, Clone, Copy)]
pub struct Outcome {
    /// Best objective value found. NaN when `status` is [`Status::InvalidArgs`] —
    /// nothing was evaluated.
    pub f: f64,
    /// Objective evaluations consumed.
    pub n_eval: usize,
    /// Why the solver stopped.
    pub status: Status,
}

/// A reusable BOBYQA solver: holds every buffer the algorithm needs, built
/// once per problem size and driven across many [`Bobyqa::minimize`] calls.
#[derive(Debug, Clone)]
pub struct Bobyqa {
    n: usize,
    config: Config,
    /// All solver scratch — sized for `(n, npt)` in [`Bobyqa::new`], the crate's only
    /// allocation site; `minimize` re-initializes whatever it reads (the zero-alloc warm
    /// path, enforced by `tests/alloc.rs`).
    ws: bobyqb::SolverWs,
}

impl Bobyqa {
    /// Allocates all scratch for an `n`-dimensional problem with `config`.
    ///
    /// # Errors
    ///
    /// [`Status::InvalidArgs`] when `(n, config)` is rejected: `n = 0`; `npt`
    /// outside `n + 2 ..= (n + 1)(n + 2) / 2` (PRIMA's preprocessing would
    /// clamp; we reject); `rho_begin` not a positive finite number; `rho_end`
    /// not in `(0, rho_begin]`; `max_fun <= npt` (PRIMA preproc would raise; we
    /// reject); `f_target` NaN.
    ///
    /// # Panics
    ///
    /// Never — invalid `(n, config)` is reported through [`Status::InvalidArgs`].
    pub fn new(n: usize, config: Config) -> Result<Self, Status> {
        if n == 0 {
            return Err(Status::InvalidArgs);
        }
        if config.npt < n + 2 {
            return Err(Status::InvalidArgs);
        }
        if config.npt > (n + 1) * (n + 2) / 2 {
            return Err(Status::InvalidArgs);
        }
        if !(config.rho_begin.is_finite() && config.rho_begin > 0.0) {
            return Err(Status::InvalidArgs);
        }
        if !(config.rho_end > 0.0 && config.rho_end <= config.rho_begin) {
            return Err(Status::InvalidArgs);
        }
        if config.max_fun < config.npt + 1 {
            return Err(Status::InvalidArgs);
        }
        if config.f_target.is_nan() {
            return Err(Status::InvalidArgs);
        }
        Ok(Self {
            n,
            config,
            ws: bobyqb::SolverWs::new(n, config.npt),
        })
    }

    /// Minimises `f` starting from `x` (overwritten with the best point
    /// found), subject to `lower <= x <= upper`, with no heap allocation in
    /// this call ([`Bobyqa::new`] owns all allocation).
    ///
    /// # Panics
    ///
    /// Never on numerical input — out-of-range arguments return an [`Outcome`]
    /// with [`Status::InvalidArgs`] rather than panicking.
    ///
    /// # Examples
    ///
    /// ```
    /// use bobyqa::{Bobyqa, Config};
    /// let mut solver = Bobyqa::new(2, Config::new(2)).unwrap();
    /// let mut x = [1.0, 2.0];
    /// let outcome = solver.minimize(
    ///     |p: &[f64]| p.iter().map(|v| v * v).sum::<f64>(),
    ///     &mut x,
    ///     &[-5.0, -5.0],
    ///     &[5.0, 5.0],
    /// );
    /// assert!(outcome.f < 1e-8);
    /// ```
    #[expect(clippy::needless_range_loop)] // explicit indexed loops mirror PRIMA (rust.md §5)
    pub fn minimize<F: FnMut(&[f64]) -> f64>(
        &mut self,
        f: F,
        x: &mut [f64],
        lower: &[f64],
        upper: &[f64],
    ) -> Outcome {
        if !self.args_are_valid(x, lower, upper) {
            // f is NaN because nothing was evaluated.
            return Outcome {
                f: f64::NAN,
                n_eval: 0,
                status: Status::InvalidArgs,
            };
        }
        // PRIMA bobyqa.f90 L287-301: clamp bounds at +/-BOUNDMAX ("no bound" sentinel,
        // consts.F90 L172). NaN bounds were rejected above; only the magnitude clamp remains.
        // The clamped copies live in the solver workspace (M2 §4: zero-alloc warm path).
        for i in 0..self.n {
            self.ws.bobyqb.xl[i] = lower[i].max(-BOUNDMAX);
            self.ws.bobyqb.xu[i] = upper[i].min(BOUNDMAX);
        }
        let rhobeg = self.config.rho_begin;

        // PRIMA bobyqa.f90 L316: x = max(xl, min(xu, moderatex(x))) — in place, elementwise
        // (each x[i] depends only on x[i], so the moderate-then-clamp order is FP-identical
        // to the former moderatex-copy-then-clamp).
        for i in 0..self.n {
            let xm = moderatex1(x[i]);
            x[i] = self.ws.bobyqb.xl[i].max(self.ws.bobyqb.xu[i].min(xm));
        }

        // PRIMA preproc.f90 L341-350 (HONOUR_X0 = FALSE — the path the oracle runs; SPEC §7.6):
        // revise X0 so its distance to each inactive bound is 0 or >= rhobeg. Valid because
        // validation guarantees XU - XL >= 2*RHOBEG and X is in the box (the L338 precondition).
        // The follow-up rhobeg-revision block (preproc.f90 L367-383) is omitted: after this
        // revision it is "unnecessary in precise arithmetic" (PRIMA's own L368 N.B.), and its
        // rounding-error repairs fall under the no-repair stance — validation rejects, never fixes.
        for i in 0..self.n {
            if x[i] <= self.ws.bobyqb.xl[i] + 0.5 * rhobeg {
                x[i] = self.ws.bobyqb.xl[i];
            } else if x[i] < self.ws.bobyqb.xl[i] + rhobeg {
                x[i] = self.ws.bobyqb.xl[i] + rhobeg;
            }
        }
        for i in 0..self.n {
            if x[i] >= self.ws.bobyqb.xu[i] - 0.5 * rhobeg {
                x[i] = self.ws.bobyqb.xu[i];
            } else if x[i] > self.ws.bobyqb.xu[i] - rhobeg {
                x[i] = self.ws.bobyqb.xu[i] - rhobeg;
            }
        }

        let mut f = f;
        let (fopt, nf, info) = bobyqb::bobyqb(
            &mut f,
            self.config.max_fun,
            self.config.npt,
            ETA1_DFT,
            ETA2_DFT,
            self.config.f_target,
            GAMMA1_DFT,
            GAMMA2_DFT,
            rhobeg,
            self.config.rho_end,
            x,
            &mut self.ws,
        );
        Outcome {
            f: fopt,
            n_eval: nf,
            status: status_from_info(info),
        }
    }

    // The per-call checks: slice lengths; bounds NaN-free, ordered, and at least
    // `2 * rho_begin` apart (PRIMA's `NO_SPACE_BETWEEN_BOUNDS`, caught up front; +/-inf bounds
    // are legal); x NaN-free. Config repair is rejected, x-space handling stays faithful to
    // PRIMA. Ordering and gap are judged on the ±BOUNDMAX-clamped bounds, mirroring PRIMA's
    // clamp-then-check order: a bound beyond ±BOUNDMAX passes the raw checks yet clamps to a
    // crossed or too-narrow box in `minimize`. Clamping only shrinks the box, so this is
    // strictly stronger than the raw checks and identical for every bound within ±BOUNDMAX.
    fn args_are_valid(&self, x: &[f64], lower: &[f64], upper: &[f64]) -> bool {
        x.len() == self.n
            && lower.len() == self.n
            && upper.len() == self.n
            && !lower.iter().chain(upper).any(|v| v.is_nan())
            && lower.iter().zip(upper).all(|(l, u)| {
                let (cl, cu) = (l.max(-BOUNDMAX), u.min(BOUNDMAX));
                cl <= cu && cu - cl >= 2.0 * self.config.rho_begin
            })
            && !x.iter().any(|v| v.is_nan())
    }
}

/// One-shot convenience over [`Bobyqa`]: infers `n` from `x.len()`, builds a
/// throwaway solver, and runs a single minimisation. **Allocates per call** — hot loops
/// re-solving many problems of one size should build a [`Bobyqa`] once and reuse it.
///
/// [`Bobyqa::new`] rejections (bad `npt`/`rho`/`max_fun`/`f_target`, or `x.len() == 0` — the
/// same `n = 0` rejection as `new`) fold into the shape `minimize` already uses for runtime
/// rejection: `Outcome { f: NaN, n_eval: 0, status: InvalidArgs }` — never a nested `Result`.
///
/// # Panics
///
/// Never — construction and runtime rejections are reported through [`Status`], not panics.
///
/// # Examples
///
/// ```
/// use bobyqa::{bobyqa, Config};
/// let mut x = [1.0, 2.0];
/// let outcome = bobyqa(
///     |p: &[f64]| p.iter().map(|v| v * v).sum::<f64>(),
///     &mut x,
///     &[-5.0, -5.0],
///     &[5.0, 5.0],
///     Config::new(2),
/// );
/// assert!(outcome.f < 1e-8);
/// ```
pub fn bobyqa<F: FnMut(&[f64]) -> f64>(
    f: F,
    x: &mut [f64],
    lower: &[f64],
    upper: &[f64],
    config: Config,
) -> Outcome {
    match Bobyqa::new(x.len(), config) {
        Ok(mut solver) => solver.minimize(f, x, lower, upper),
        // `new` only ever fails with InvalidArgs; keep its word rather than re-spelling it.
        Err(status) => Outcome {
            f: f64::NAN,
            n_eval: 0,
            status,
        },
    }
}

// The PRIMA-info -> `Status` mapping. `SMALL_TR_RADIUS` shares PRIMA's value 0 with `INFO_DFT`
// (a normal loop exit IS convergence). `MAXTR_REACHED` is budget-class (`maxtr = 2 * max_fun`,
// near-unreachable). `NAN_INF_X`/`NAN_INF_F` are `checkexit` defensive guards, near-unreachable
// behind `moderatex`/`moderatef` — numerical-breakdown class. `NO_SPACE_BETWEEN_BOUNDS` is
// caught by validation before the loop. `TRSUBP_FAILED` is never emitted by the BOBYQA port
// (`trsbox` returns only CRVMIN, no info code) — the constant exists for completeness against
// PRIMA's infos.f90, so it is absent here.
fn status_from_info(info: i32) -> Status {
    use crate::consts::{
        DAMAGING_ROUNDING, FTARGET_ACHIEVED, MAXFUN_REACHED, MAXTR_REACHED, NAN_INF_F,
        NAN_INF_MODEL, NAN_INF_X, SMALL_TR_RADIUS,
    };
    match info {
        SMALL_TR_RADIUS => Status::Converged,
        FTARGET_ACHIEVED => Status::TargetReached,
        MAXFUN_REACHED | MAXTR_REACHED => Status::MaxFunReached,
        NAN_INF_MODEL | DAMAGING_ROUNDING | NAN_INF_X | NAN_INF_F => Status::ModelDegenerate,
        // bobyqb's info set is closed; a new code here is a port bug or a spec amendment to
        // raise, never a silent mapping.
        other => {
            debug_assert!(false, "unmapped PRIMA info {other}");
            Status::ModelDegenerate
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn valid() -> (usize, Config) {
        (2, Config::new(2))
    }

    #[test]
    fn config_new_returns_prima_defaults() {
        let c = Config::new(3);
        assert_eq!(c.npt, 7); // 2n + 1
        assert_eq!(c.rho_begin, 1.0);
        assert_eq!(c.rho_end, 1e-6);
        assert_eq!(c.max_fun, 1500); // 500 * n
        assert_eq!(c.f_target, f64::NEG_INFINITY);
    }

    #[test]
    fn new_accepts_the_default_config_and_the_npt_extremes() {
        let (n, c) = valid();
        assert!(Bobyqa::new(n, c).is_ok());
        assert!(Bobyqa::new(n, Config { npt: 4, ..c }).is_ok()); // n + 2
        assert!(Bobyqa::new(n, Config { npt: 6, ..c }).is_ok()); // (n+1)(n+2)/2
    }

    #[test]
    fn new_rejects_bad_n_npt_rho_maxfun_ftarget() {
        let (n, c) = valid();
        assert!(Bobyqa::new(0, Config::new(1)).is_err());
        assert!(Bobyqa::new(n, Config { npt: 3, ..c }).is_err()); // < n + 2: PRIMA preproc would clamp; we reject
        assert!(Bobyqa::new(n, Config { npt: 7, ..c }).is_err()); // > (n+1)(n+2)/2
        assert!(Bobyqa::new(n, Config { rho_end: 0.0, ..c }).is_err());
        assert!(Bobyqa::new(n, Config { rho_end: 2.0, ..c }).is_err()); // > rho_begin
        assert!(
            Bobyqa::new(
                n,
                Config {
                    rho_begin: 0.0,
                    ..c
                }
            )
            .is_err()
        ); // rho_begin must be > 0
        assert!(
            Bobyqa::new(
                n,
                Config {
                    rho_begin: -1.0,
                    ..c
                }
            )
            .is_err()
        ); // (only +inf was tested before)
        assert!(
            Bobyqa::new(
                n,
                Config {
                    rho_begin: f64::INFINITY,
                    rho_end: 1.0,
                    ..c
                }
            )
            .is_err()
        );
        assert!(Bobyqa::new(n, Config { max_fun: 5, ..c }).is_err()); // < npt + 1: PRIMA preproc would raise; we reject
        assert!(
            Bobyqa::new(
                n,
                Config {
                    f_target: f64::NAN,
                    ..c
                }
            )
            .is_err()
        );
    }

    fn invalid_outcome(o: Outcome) {
        assert_eq!(o.status, Status::InvalidArgs);
        assert!(o.f.is_nan());
        assert_eq!(o.n_eval, 0);
    }

    #[test]
    fn minimize_rejects_bad_runtime_arguments() {
        let (n, c) = valid();
        let mut s = Bobyqa::new(n, c).unwrap();
        let f = |x: &[f64]| x[0];
        invalid_outcome(s.minimize(f, &mut [0.0], &[-1.0, -1.0], &[1.0, 1.0])); // x len
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0], &[1.0, 1.0])); // lower len
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0, -1.0], &[1.0])); // upper len
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[f64::NAN, -1.0], &[1.0, 1.0])); // lower NaN
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0, -1.0], &[f64::NAN, 1.0])); // upper NaN: exercises the `upper` half of the .chain() the lower-only case can't
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[2.0, -1.0], &[1.0, 1.0])); // crossed
        invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-0.5, -1.0], &[0.5, 1.0])); // upper-lower < 2*rho_begin
        invalid_outcome(s.minimize(f, &mut [f64::NAN, 0.0], &[-9.0, -9.0], &[9.0, 9.0]));
    }

    #[test]
    fn minimize_rejects_bounds_that_cross_or_collapse_after_the_boundmax_clamp() {
        // A bound beyond ±BOUNDMAX passes the raw ordering/gap checks (lower <= upper, gap
        // = +inf) yet clamps to a crossed or zero-width box — validation must judge the
        // clamped values, like PRIMA (clamp at bobyqa.f90 L287-301 precedes its checks).
        let (n, c) = valid();
        let mut s = Bobyqa::new(n, c).unwrap();
        let f = |x: &[f64]| x[0];
        // upper < -BOUNDMAX: clamps to xl = -BOUNDMAX > xu = -1e308 (crossed).
        let below = -1e308;
        invalid_outcome(s.minimize(
            f,
            &mut [0.0, 0.0],
            &[f64::NEG_INFINITY, -9.0],
            &[below, 9.0],
        ));
        // upper = -BOUNDMAX exactly: clamps to a zero-width box (gap < 2 * rho_begin).
        invalid_outcome(s.minimize(
            f,
            &mut [0.0, 0.0],
            &[f64::NEG_INFINITY, -9.0],
            &[-BOUNDMAX, 9.0],
        ));
    }

    #[test]
    #[expect(clippy::many_single_char_names)] // n, c, s, x, o are PRIMA/test shorthands
    fn minimize_converges_on_the_sphere_and_reports_the_trajectory_floor() {
        let (n, c) = valid();
        let mut s = Bobyqa::new(n, c).unwrap();
        let mut n_calls = 0usize;
        let mut x = [1.0, 2.0];
        let o = s.minimize(
            |p: &[f64]| {
                n_calls += 1;
                p.iter().map(|v| v * v).sum::<f64>()
            },
            &mut x,
            &[-5.0, -5.0],
            &[5.0, 5.0],
        );
        assert_eq!(o.status, Status::Converged);
        assert_eq!(o.n_eval, n_calls);
        assert!(o.f < 1e-8);
        assert!(x.iter().all(|v| v.abs() < 1e-3));
        assert!(o.n_eval >= c.npt); // the npt model-building floor (SPEC §1)
    }

    #[test]
    fn minimize_projects_a_near_bound_start_onto_the_bound_like_prima_preproc() {
        // preproc.f90 L341-343: x0 within rhobeg/2 of a bound starts ON the bound — the first
        // evaluation (at XBASE) must sit exactly there.
        let (n, c) = valid(); // rho_begin = 1.0
        let mut s = Bobyqa::new(n, c).unwrap();
        let mut first_x0 = f64::NAN;
        let mut seen = false;
        let mut x = [1.4, 0.3]; // 0.4 (< rho_begin/2) above lower[0] = 1.0
        s.minimize(
            |p: &[f64]| {
                if !seen {
                    first_x0 = p[0];
                    seen = true;
                }
                p.iter().map(|v| v * v).sum::<f64>()
            },
            &mut x,
            &[1.0, -5.0],
            &[6.0, 5.0],
        );
        assert_eq!(first_x0, 1.0);
    }

    #[test]
    fn minimize_stops_on_f_target_and_on_the_budget() {
        let (n, c) = valid();
        let mut s = Bobyqa::new(n, Config { f_target: 0.5, ..c }).unwrap();
        let sphere = |p: &[f64]| p.iter().map(|v| v * v).sum::<f64>();
        let o = s.minimize(sphere, &mut [1.0, 2.0], &[-5.0, -5.0], &[5.0, 5.0]);
        assert_eq!(o.status, Status::TargetReached);
        assert!(o.f <= 0.5);

        let mut s = Bobyqa::new(n, Config { max_fun: 6, ..c }).unwrap(); // npt + 1
        let o = s.minimize(sphere, &mut [1.0, 2.0], &[-5.0, -5.0], &[5.0, 5.0]);
        assert_eq!(o.status, Status::MaxFunReached);
        assert_eq!(o.n_eval, 6);
    }

    #[test]
    #[expect(clippy::many_single_char_names)] // n, c, s, x, o are PRIMA/test shorthands
    fn minimize_accepts_infinite_bounds_via_the_boundmax_clamp() {
        // |bound| >= BOUNDMAX means "no bound" (design §4.2); minimize clamps to +/-BOUNDMAX
        // (bobyqa.f90 L287-301) and must run, not panic.
        let (n, c) = valid();
        let mut s = Bobyqa::new(n, c).unwrap();
        let mut x = [1.0, 2.0];
        let o = s.minimize(
            |p: &[f64]| p.iter().map(|v| v * v).sum::<f64>(),
            &mut x,
            &[f64::NEG_INFINITY, -9.0],
            &[f64::INFINITY, 9.0],
        );
        assert_eq!(o.status, Status::Converged);
        assert!(o.f < 1e-8);
    }

    #[test]
    fn status_from_info_maps_every_reachable_prima_code() {
        use crate::consts::*;
        assert_eq!(status_from_info(SMALL_TR_RADIUS), Status::Converged);
        assert_eq!(status_from_info(FTARGET_ACHIEVED), Status::TargetReached);
        assert_eq!(status_from_info(MAXFUN_REACHED), Status::MaxFunReached);
        assert_eq!(status_from_info(MAXTR_REACHED), Status::MaxFunReached);
        assert_eq!(status_from_info(NAN_INF_MODEL), Status::ModelDegenerate);
        assert_eq!(status_from_info(DAMAGING_ROUNDING), Status::ModelDegenerate);
        assert_eq!(status_from_info(NAN_INF_X), Status::ModelDegenerate);
        assert_eq!(status_from_info(NAN_INF_F), Status::ModelDegenerate);
    }

    #[test]
    fn status_displays_and_is_an_error() {
        let e: &dyn std::error::Error = &Status::InvalidArgs;
        assert!(!e.to_string().is_empty());
    }

    #[test]
    fn one_shot_bobyqa_minimizes_and_folds_construction_errors_into_the_outcome() {
        // Happy path: same sphere as the reusable-solver test.
        let mut x = [1.0, 2.0];
        let o = bobyqa(
            |p: &[f64]| p.iter().map(|v| v * v).sum::<f64>(),
            &mut x,
            &[-5.0, -5.0],
            &[5.0, 5.0],
            Config::new(2),
        );
        assert_eq!(o.status, Status::Converged);
        assert!(o.f < 1e-8);

        // Construction rejections arrive as the InvalidArgs Outcome, not a Result.
        let f = |p: &[f64]| p[0];
        invalid_outcome(bobyqa(f, &mut [], &[], &[], Config::new(1))); // x.len() == 0 -> n = 0
        let bad_npt = Config {
            npt: 99,
            ..Config::new(2)
        };
        invalid_outcome(bobyqa(
            f,
            &mut [0.0, 0.0],
            &[-9.0, -9.0],
            &[9.0, 9.0],
            bad_npt,
        ));
    }
}