use nalgebra::{DMatrix, DVector};
use thiserror::Error;
use crate::numerics::utils::{is_near_zero, norm_l2};
#[derive(Debug, Error, PartialEq)]
pub enum BroydenError {
#[error(
"did not converge in {max_iter} iterations (||F||={last_f_norm}, ||Δx||={last_dx_norm})"
)]
NoConvergence {
max_iter: usize,
last_f_norm: f64,
last_dx_norm: f64,
},
#[error("Jacobian became singular at iteration {iter}; cannot solve for step")]
SingularJacobian { iter: usize },
#[error("F returned non-finite value at x = {x:?}")]
NonFiniteEvaluation { x: Vec<f64> },
#[error("F returned a vector of length {got}, expected {expected}")]
DimensionMismatch { expected: usize, got: usize },
}
#[derive(Debug, Clone, Copy)]
pub struct BroydenConfig {
pub xtol: f64,
pub ftol: f64,
pub max_iter: usize,
pub refresh_every: usize,
pub fd_step: f64,
}
impl Default for BroydenConfig {
fn default() -> Self {
Self {
xtol: 1e-8,
ftol: 1e-8,
max_iter: 100,
refresh_every: 5,
fd_step: 1e-7,
}
}
}
pub fn broyden<F>(mut f: F, x0: &[f64], cfg: BroydenConfig) -> Result<Vec<f64>, BroydenError>
where
F: FnMut(&[f64]) -> Vec<f64>,
{
let n = x0.len();
let mut x = DVector::from_column_slice(x0);
let mut fx = eval(&mut f, &x, n)?;
if norm_l2(fx.as_slice()) <= cfg.ftol {
return Ok(x.iter().copied().collect());
}
let mut j = finite_diff_jacobian(&mut f, &x, &fx, n, cfg.fd_step)?;
let mut updates_since_refresh: usize = 0;
for iter in 0..cfg.max_iter {
let neg_fx = -fx.clone();
let dx = j
.clone()
.lu()
.solve(&neg_fx)
.ok_or(BroydenError::SingularJacobian { iter })?;
let x_new = &x + &dx;
let fx_new = eval(&mut f, &x_new, n)?;
let dx_norm = norm_l2(dx.as_slice());
let f_norm = norm_l2(fx_new.as_slice());
if f_norm <= cfg.ftol || dx_norm <= cfg.xtol {
return Ok(x_new.iter().copied().collect());
}
let should_refresh =
cfg.refresh_every != 0 && updates_since_refresh + 1 >= cfg.refresh_every;
if should_refresh {
j = finite_diff_jacobian(&mut f, &x_new, &fx_new, n, cfg.fd_step)?;
updates_since_refresh = 0;
} else {
let dy = &fx_new - &fx;
let j_dx = &j * &dx;
let denom = dx.dot(&dx);
if is_near_zero(denom) {
j = finite_diff_jacobian(&mut f, &x_new, &fx_new, n, cfg.fd_step)?;
updates_since_refresh = 0;
} else {
let numerator = &dy - &j_dx;
for i in 0..n {
for k in 0..n {
j[(i, k)] += numerator[i] * dx[k] / denom;
}
}
updates_since_refresh += 1;
}
}
x = x_new;
fx = fx_new;
if iter + 1 == cfg.max_iter {
return Err(BroydenError::NoConvergence {
max_iter: cfg.max_iter,
last_f_norm: f_norm,
last_dx_norm: dx_norm,
});
}
}
unreachable!("loop exits via convergence return or NoConvergence")
}
fn eval<F>(f: &mut F, x: &DVector<f64>, n: usize) -> Result<DVector<f64>, BroydenError>
where
F: FnMut(&[f64]) -> Vec<f64>,
{
let raw = f(x.as_slice());
if raw.len() != n {
return Err(BroydenError::DimensionMismatch {
expected: n,
got: raw.len(),
});
}
if !raw.iter().all(|v| v.is_finite()) {
return Err(BroydenError::NonFiniteEvaluation {
x: x.iter().copied().collect(),
});
}
Ok(DVector::from_vec(raw))
}
fn finite_diff_jacobian<F>(
f: &mut F,
x: &DVector<f64>,
fx: &DVector<f64>,
n: usize,
h: f64,
) -> Result<DMatrix<f64>, BroydenError>
where
F: FnMut(&[f64]) -> Vec<f64>,
{
let mut j = DMatrix::zeros(n, n);
let mut x_pert = x.clone();
for col in 0..n {
let h_col = h * (1.0 + x[col].abs());
x_pert[col] = x[col] + h_col;
let f_pert = eval(f, &x_pert, n)?;
for row in 0..n {
j[(row, col)] = (f_pert[row] - fx[row]) / h_col;
}
x_pert[col] = x[col]; }
Ok(j)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(actual: f64, expected: f64, tol: f64) {
assert!(
(actual - expected).abs() < tol,
"expected {expected}, got {actual}"
);
}
#[test]
fn solves_simple_2x2_polynomial_system() {
let f = |v: &[f64]| {
let (x, y) = (v[0], v[1]);
vec![x * x + y * y - 2.0, x * y - 1.0]
};
let cfg = BroydenConfig {
ftol: 1e-14,
xtol: 1e-14,
..Default::default()
};
let root = broyden(f, &[0.5, 1.5], cfg).unwrap();
assert_close(root[0], 1.0, 1e-6);
assert_close(root[1], 1.0, 1e-6);
}
#[test]
fn solves_linear_system_essentially_immediately() {
let f = |v: &[f64]| {
vec![
2.0 * v[0] + v[1] - 3.0,
v[0] + 3.0 * v[1] + v[2] - 5.0,
v[1] + 2.0 * v[2] - 3.0,
]
};
let root = broyden(f, &[0.0, 0.0, 0.0], BroydenConfig::default()).unwrap();
for &r in &root {
assert_close(r, 1.0, 1e-7);
}
}
#[test]
fn solves_rosenbrock_style_system() {
let f = |v: &[f64]| {
let (x, y) = (v[0], v[1]);
vec![10.0 * (y - x * x), 1.0 - x]
};
let root = broyden(f, &[-1.2, 1.0], BroydenConfig::default()).unwrap();
assert_close(root[0], 1.0, 1e-7);
assert_close(root[1], 1.0, 1e-7);
}
#[test]
fn refresh_cadence_does_not_break_convergence() {
let f = |v: &[f64]| {
let (x, y) = (v[0], v[1]);
vec![x * x + y * y - 2.0, x * y - 1.0]
};
for refresh_every in [0_usize, 1, 2, 5, 10] {
let cfg = BroydenConfig {
refresh_every,
ftol: 1e-13,
xtol: 1e-13,
..Default::default()
};
let root = broyden(f, &[0.5, 1.5], cfg).unwrap_or_else(|e| {
panic!("refresh_every={refresh_every} failed: {e}");
});
assert_close(root[0], 1.0, 1e-5);
assert_close(root[1], 1.0, 1e-5);
}
}
#[test]
fn detects_singular_jacobian() {
let f = |v: &[f64]| vec![v[0] + v[1] - 1.0, 2.0 * (v[0] + v[1]) - 2.0];
let err = broyden(f, &[0.0, 0.0], BroydenConfig::default()).unwrap_err();
assert!(matches!(err, BroydenError::SingularJacobian { .. }));
}
#[test]
fn detects_dimension_mismatch() {
let f = |_v: &[f64]| vec![1.0, 2.0, 3.0];
let err = broyden(f, &[0.0, 0.0], BroydenConfig::default()).unwrap_err();
assert!(matches!(err, BroydenError::DimensionMismatch { .. }));
}
#[test]
fn detects_non_finite_evaluation() {
let f = |_v: &[f64]| vec![f64::NAN, 0.0];
let err = broyden(f, &[0.0, 0.0], BroydenConfig::default()).unwrap_err();
assert!(matches!(err, BroydenError::NonFiniteEvaluation { .. }));
}
#[test]
fn reports_no_convergence_with_residuals() {
let f = |v: &[f64]| {
let (x, y) = (v[0], v[1]);
vec![x.exp() + y - 2.0, x + y.exp() - 2.0]
};
let cfg = BroydenConfig {
max_iter: 3,
..Default::default()
};
let err = broyden(f, &[2.0, 2.0], cfg).unwrap_err();
match err {
BroydenError::NoConvergence {
max_iter,
last_f_norm,
last_dx_norm,
} => {
assert_eq!(max_iter, 3);
assert!(last_f_norm > 0.0, "residual norm should be positive");
assert!(last_dx_norm >= 0.0, "step norm should be non-negative");
}
other => panic!("expected NoConvergence, got {other:?}"),
}
}
}