math_audio_optimisation/cobyla.rs
1//! Pure-Rust COBYLA local optimizer.
2//!
3//! Wraps the [`cobyla`](https://crates.io/crates/cobyla) crate (a faithful
4//! pure-Rust port of NLopt's `cobyla.c`, itself a port of M.J.D. Powell's
5//! 1994 FORTRAN implementation) behind an API consistent with this crate's
6//! [`differential_evolution`](crate::differential_evolution) and
7//! [`levenberg_marquardt`](crate::levenberg_marquardt) entry points.
8//!
9//! # Constraint convention
10//!
11//! Inequality constraints are passed in the **autoeq/NLopt convention**:
12//! `g_i(x) <= 0` is feasible. Internally this module flips the sign before
13//! handing them to the `cobyla` crate, which uses the opposite convention
14//! (`fc(x) >= 0` is feasible).
15//!
16//! # Example
17//!
18//! ```rust
19//! use math_audio_optimisation::cobyla::{cobyla, CobylaConfig, CobylaConstraint, CobylaRhoBegin};
20//! use ndarray::Array1;
21//! use std::sync::Arc;
22//!
23//! // Minimize 10*(x0+1)^2 + x1^2 subject to x0 >= 0
24//! // (i.e. constraint -x0 <= 0 in our convention)
25//! let f = |x: &Array1<f64>| 10.0 * (x[0] + 1.0).powi(2) + x[1].powi(2);
26//! let constraints = vec![CobylaConstraint {
27//! fun: Arc::new(|x: &Array1<f64>| -x[0]),
28//! }];
29//! let cfg = CobylaConfig {
30//! x0: Array1::from(vec![1.0, 1.0]),
31//! bounds: vec![(-10.0, 10.0), (-10.0, 10.0)],
32//! maxeval: 200,
33//! rho_begin: CobylaRhoBegin::All(0.5),
34//! ..Default::default()
35//! };
36//! let report = cobyla(&f, &constraints, cfg).expect("cobyla should run");
37//! assert!(report.x[0] >= -1e-3, "x0 must be roughly >= 0");
38//! assert!(report.fun < 10.1, "constrained minimum is at (0, 0) with value 10");
39//! ```
40
41use ndarray::Array1;
42use std::sync::Arc;
43
44use crate::error::{DEError, Result};
45
46/// Erased inequality-constraint closure. Returns `<= 0` when feasible
47/// (NLopt convention; the wrapper flips the sign for the `cobyla` crate).
48pub type CobylaConstraintFn = Arc<dyn Fn(&Array1<f64>) -> f64 + Send + Sync>;
49
50/// A single inequality constraint `fun(x) <= 0`.
51#[derive(Clone)]
52pub struct CobylaConstraint {
53 /// Constraint function. Feasible when `<= 0`.
54 pub fun: CobylaConstraintFn,
55}
56
57/// Termination tolerances passed through to NLopt's `nlopt_stopping`.
58#[derive(Debug, Clone, Copy)]
59pub struct CobylaStopTols {
60 /// Stop when objective drops at or below `stopval`. Disabled if
61 /// non-positive (mapped to `-INFINITY` internally). The upstream
62 /// `cobyla` crate's `minimize()` hardcoded this to `-INFINITY` —
63 /// re-exposing it as a tuning knob.
64 pub stopval: f64,
65 /// Stop when |Δf| < `ftol_abs`. Disabled if non-positive.
66 pub ftol_abs: f64,
67 /// Stop when |Δf|/|f| < `ftol_rel`. Disabled if non-positive.
68 pub ftol_rel: f64,
69 /// Stop when ||Δx||_inf < `xtol_abs`. Disabled if non-positive.
70 pub xtol_abs: f64,
71 /// Stop when ||Δx||/||x|| < `xtol_rel`. Disabled if non-positive.
72 pub xtol_rel: f64,
73}
74
75impl Default for CobylaStopTols {
76 fn default() -> Self {
77 // Match NLopt-via-autoeq settings (the previous `optim/nlopt.rs`
78 // before the C-FFI dep was dropped):
79 // set_stopval(1e-4); set_ftol_rel(1e-6); set_xtol_rel(1e-4);
80 Self {
81 stopval: 1e-4,
82 ftol_abs: 0.0,
83 ftol_rel: 1e-6,
84 xtol_abs: 0.0,
85 xtol_rel: 1e-4,
86 }
87 }
88}
89
90/// Initial trust-region radius spec.
91///
92/// Powell's COBYLA accepts a single `rhobeg` applied to all variables, or
93/// per-dimension initial steps. For autoeq's PEQ problem the parameters
94/// differ in scale by orders of magnitude (log10-frequency span ~0.5,
95/// gain span ~24 dB), so per-dimension steps are recommended.
96#[derive(Debug, Clone)]
97pub enum CobylaRhoBegin {
98 /// Same initial step for every dimension.
99 All(f64),
100 /// Per-dimension initial steps. Length must equal `x0.len()`.
101 PerDim(Vec<f64>),
102}
103
104impl Default for CobylaRhoBegin {
105 fn default() -> Self {
106 Self::All(0.5)
107 }
108}
109
110/// Configuration for [`cobyla`].
111#[derive(Clone)]
112pub struct CobylaConfig {
113 /// Initial guess. Must lie within `bounds`.
114 pub x0: Array1<f64>,
115 /// `(lower, upper)` per dimension.
116 pub bounds: Vec<(f64, f64)>,
117 /// Initial trust-region radius. See [`CobylaRhoBegin`].
118 pub rho_begin: CobylaRhoBegin,
119 /// Maximum number of objective evaluations.
120 pub maxeval: usize,
121 /// Termination tolerances.
122 pub stop_tol: CobylaStopTols,
123}
124
125impl Default for CobylaConfig {
126 fn default() -> Self {
127 Self {
128 x0: Array1::zeros(0),
129 bounds: Vec::new(),
130 rho_begin: CobylaRhoBegin::default(),
131 maxeval: 1000,
132 stop_tol: CobylaStopTols::default(),
133 }
134 }
135}
136
137/// Result of a [`cobyla`] run.
138#[derive(Clone)]
139pub struct CobylaReport {
140 /// Best parameter vector found.
141 pub x: Array1<f64>,
142 /// Objective value at `x`.
143 pub fun: f64,
144 /// Whether the underlying solver returned a success status.
145 pub success: bool,
146 /// Human-readable status (Debug-formatted underlying status).
147 pub message: String,
148 /// Number of function evaluations consumed (best-effort — the
149 /// underlying crate does not expose an exact count, so this is the
150 /// configured `maxeval` budget when unknown).
151 pub nfev: usize,
152}
153
154impl std::fmt::Debug for CobylaReport {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("CobylaReport")
157 .field("x_len", &self.x.len())
158 .field("fun", &self.fun)
159 .field("success", &self.success)
160 .field("message", &self.message)
161 .field("nfev", &self.nfev)
162 .finish()
163 }
164}
165
166/// Minimize `f` subject to bounds and inequality constraints `g_i(x) <= 0`.
167///
168/// On success returns the best point found. On a non-success termination
169/// the report is still populated with the last point evaluated so callers
170/// can decide whether the result is usable.
171pub fn cobyla<F>(
172 f: &F,
173 constraints: &[CobylaConstraint],
174 config: CobylaConfig,
175) -> Result<CobylaReport>
176where
177 F: Fn(&Array1<f64>) -> f64 + Sync,
178{
179 let n = config.x0.len();
180 if n == 0 {
181 return Err(DEError::BoundsMismatch {
182 lower_len: 0,
183 upper_len: 0,
184 });
185 }
186 if config.bounds.len() != n {
187 return Err(DEError::BoundsMismatch {
188 lower_len: config.bounds.len(),
189 upper_len: n,
190 });
191 }
192 for (i, (lo, hi)) in config.bounds.iter().enumerate() {
193 if lo > hi {
194 return Err(DEError::InvalidBounds {
195 index: i,
196 lower: *lo,
197 upper: *hi,
198 });
199 }
200 }
201
202 // Translate config into the `cobyla_native` API (hand-translated
203 // Rust port of NLopt 2.7.1's cobyla.c, replacing the c2rust output).
204 use crate::cobyla_native as native;
205
206 // Build per-dim initial steps (rhobeg).
207 let dx: Vec<f64> = match &config.rho_begin {
208 CobylaRhoBegin::All(r) => vec![*r; n],
209 CobylaRhoBegin::PerDim(v) => {
210 if v.len() != n {
211 return Err(DEError::BoundsMismatch {
212 lower_len: v.len(),
213 upper_len: n,
214 });
215 }
216 v.clone()
217 }
218 };
219
220 // Stop criteria — hand-port honours these directly (no wrapper bugs to
221 // work around).
222 let stopval = if config.stop_tol.stopval > 0.0 {
223 config.stop_tol.stopval
224 } else {
225 f64::NEG_INFINITY
226 };
227 let stop = native::StopCriteria {
228 stopval,
229 ftol_abs: config.stop_tol.ftol_abs,
230 ftol_rel: config.stop_tol.ftol_rel,
231 xtol_abs: config.stop_tol.xtol_abs,
232 xtol_rel: config.stop_tol.xtol_rel,
233 maxeval: config.maxeval,
234 };
235
236 // Wrap user-facing closures (Array1) as &[f64] for the native API.
237 // Constraint convention is identical: feasible when `g(x) <= 0`.
238 let f_native = |x: &[f64]| -> f64 {
239 let arr = Array1::from(x.to_vec());
240 f(&arr)
241 };
242 type SliceObj = Box<dyn Fn(&[f64]) -> f64>;
243 let cons_native: Vec<SliceObj> = constraints
244 .iter()
245 .map(|c| {
246 let g = c.fun.clone();
247 let b: SliceObj = Box::new(move |x: &[f64]| {
248 let arr = Array1::from(x.to_vec());
249 g(&arr)
250 });
251 b
252 })
253 .collect();
254
255 let mut x_buf: Vec<f64> = config.x0.to_vec();
256 let report = native::cobyla_native(
257 n,
258 f_native,
259 &cons_native,
260 &config.bounds,
261 &mut x_buf,
262 &dx,
263 &stop,
264 )?;
265
266 Ok(CobylaReport {
267 x: Array1::from(report.x),
268 fun: report.fun,
269 success: report.success,
270 message: report.message.to_string(),
271 nfev: report.nfev,
272 })
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn paraboloid_unconstrained() {
281 // f(x) = (x0+1)^2 + x1^2, minimum at (-1, 0) with value 0.
282 let f = |x: &Array1<f64>| (x[0] + 1.0).powi(2) + x[1].powi(2);
283 let cfg = CobylaConfig {
284 x0: Array1::from(vec![1.0, 1.0]),
285 bounds: vec![(-10.0, 10.0), (-10.0, 10.0)],
286 rho_begin: CobylaRhoBegin::All(0.5),
287 maxeval: 500,
288 // Disable stopval so the test can drive convergence below 1e-4.
289 stop_tol: CobylaStopTols {
290 stopval: 0.0,
291 ..CobylaStopTols::default()
292 },
293 };
294 let report = cobyla(&f, &[], cfg).expect("cobyla failed");
295 assert!(report.fun < 1e-6, "fun = {} should be ~0", report.fun);
296 assert!((report.x[0] - (-1.0)).abs() < 1e-3);
297 assert!(report.x[1].abs() < 1e-3);
298 }
299
300 #[test]
301 fn paraboloid_with_inequality() {
302 // Minimize (x0+1)^2 + x1^2 subject to x0 >= 0 (i.e. -x0 <= 0).
303 // Constrained optimum at (0, 0) with value 1.
304 let f = |x: &Array1<f64>| (x[0] + 1.0).powi(2) + x[1].powi(2);
305 let constraints = vec![CobylaConstraint {
306 fun: Arc::new(|x: &Array1<f64>| -x[0]),
307 }];
308 let cfg = CobylaConfig {
309 x0: Array1::from(vec![1.0, 1.0]),
310 bounds: vec![(-10.0, 10.0), (-10.0, 10.0)],
311 rho_begin: CobylaRhoBegin::All(0.5),
312 maxeval: 500,
313 ..Default::default()
314 };
315 let report = cobyla(&f, &constraints, cfg).expect("cobyla failed");
316 // Active constraint at x0 = 0 ⇒ f = 1.
317 assert!(
318 (report.fun - 1.0).abs() < 1e-3,
319 "fun = {} should be ~1",
320 report.fun
321 );
322 assert!(
323 report.x[0] >= -1e-3,
324 "x0 = {} should respect x0 >= 0",
325 report.x[0]
326 );
327 }
328
329 #[test]
330 fn rejects_bad_bounds() {
331 let f = |x: &Array1<f64>| x[0] * x[0];
332 let cfg = CobylaConfig {
333 x0: Array1::from(vec![0.0]),
334 bounds: vec![(1.0, -1.0)], // lo > hi
335 rho_begin: CobylaRhoBegin::All(0.1),
336 maxeval: 100,
337 ..Default::default()
338 };
339 let err = cobyla(&f, &[], cfg).unwrap_err();
340 matches!(err, DEError::InvalidBounds { .. });
341 }
342}