#![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; 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;
#[derive(Debug, Clone, Copy)]
pub struct Config {
pub npt: usize,
pub rho_begin: f64,
pub rho_end: f64,
pub max_fun: usize,
pub f_target: f64,
}
impl Config {
#[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,
f_target: f64::NEG_INFINITY,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Converged,
TargetReached,
MaxFunReached,
ModelDegenerate,
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 {}
#[derive(Debug, Clone, Copy)]
pub struct Outcome {
pub f: f64,
pub n_eval: usize,
pub status: Status,
}
#[derive(Debug, Clone)]
pub struct Bobyqa {
n: usize,
config: Config,
ws: bobyqb::SolverWs,
}
impl Bobyqa {
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),
})
}
#[expect(clippy::needless_range_loop)] 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) {
return Outcome {
f: f64::NAN,
n_eval: 0,
status: Status::InvalidArgs,
};
}
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;
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));
}
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),
}
}
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())
}
}
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),
Err(status) => Outcome {
f: f64::NAN,
n_eval: 0,
status,
},
}
}
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,
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); assert_eq!(c.rho_begin, 1.0);
assert_eq!(c.rho_end, 1e-6);
assert_eq!(c.max_fun, 1500); 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()); assert!(Bobyqa::new(n, Config { npt: 6, ..c }).is_ok()); }
#[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()); assert!(Bobyqa::new(n, Config { npt: 7, ..c }).is_err()); assert!(Bobyqa::new(n, Config { rho_end: 0.0, ..c }).is_err());
assert!(Bobyqa::new(n, Config { rho_end: 2.0, ..c }).is_err()); assert!(
Bobyqa::new(
n,
Config {
rho_begin: 0.0,
..c
}
)
.is_err()
); assert!(
Bobyqa::new(
n,
Config {
rho_begin: -1.0,
..c
}
)
.is_err()
); 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()); 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])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0], &[1.0, 1.0])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0, -1.0], &[1.0])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[f64::NAN, -1.0], &[1.0, 1.0])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-1.0, -1.0], &[f64::NAN, 1.0])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[2.0, -1.0], &[1.0, 1.0])); invalid_outcome(s.minimize(f, &mut [0.0, 0.0], &[-0.5, -1.0], &[0.5, 1.0])); 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() {
let (n, c) = valid();
let mut s = Bobyqa::new(n, c).unwrap();
let f = |x: &[f64]| x[0];
let below = -1e308;
invalid_outcome(s.minimize(
f,
&mut [0.0, 0.0],
&[f64::NEG_INFINITY, -9.0],
&[below, 9.0],
));
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)] 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); }
#[test]
fn minimize_projects_a_near_bound_start_onto_the_bound_like_prima_preproc() {
let (n, c) = valid(); 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]; 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(); 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)] fn minimize_accepts_infinite_bounds_via_the_boundmax_clamp() {
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() {
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);
let f = |p: &[f64]| p[0];
invalid_outcome(bobyqa(f, &mut [], &[], &[], Config::new(1))); 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,
));
}
}