#![allow(clippy::needless_range_loop)]
use crate::linalg::generic::{mat_cholesky, mat_inv, mat_symmetrize};
#[derive(Clone, Debug)]
pub struct NLLSEvaluation<const N: usize> {
pub residuals: Vec<f64>,
pub jacobian: Vec<[f64; N]>,
pub cost: f64,
}
#[derive(Clone, Debug)]
pub struct NLLSPrior<const N: usize> {
pub mean: [f64; N],
pub covariance_inv: [[f64; N]; N],
}
const MU_SEED: f64 = 1e-12;
const D_FLOOR_REL: f64 = 1e-12;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct LMConfig {
pub max_iterations: usize,
pub max_inner_trials: usize,
pub tau: f64,
pub mu_max: f64,
pub min_relative_decrease: f64,
pub gtol: f64,
pub qtol: f64,
pub xtol: f64,
pub ftol: f64,
pub max_consecutive_invalid: usize,
pub geodesic_acceleration: bool,
pub avmax: f64,
}
impl Default for LMConfig {
fn default() -> Self {
Self {
max_iterations: 100,
max_inner_trials: 30,
tau: 1e-3,
mu_max: 1e32,
min_relative_decrease: 1e-4,
gtol: 1e-8,
qtol: 0.0,
xtol: 1e-8,
ftol: 1.49e-8,
max_consecutive_invalid: 5,
geodesic_acceleration: false,
avmax: 0.75,
}
}
}
pub trait CostProblem<const N: usize> {
type Error: std::error::Error + 'static;
fn evaluate_cost(&mut self, x: &[f64; N]) -> Result<f64, Self::Error>;
fn constrain_step(&mut self, _x: &[f64; N], _delta: &mut [f64; N]) {}
fn on_step_accepted(&mut self, _x: &[f64; N]) {}
fn on_step_rejected(&mut self, _x_trial: &[f64; N]) {}
fn second_directional_derivative(&mut self, _x: &[f64; N], _v: &[f64; N]) -> Option<Vec<f64>> {
None
}
}
pub trait ResidualProblem<const N: usize>: CostProblem<N> {
fn evaluate(&mut self, x: &[f64; N]) -> Result<NLLSEvaluation<N>, Self::Error>;
}
#[derive(Clone, Debug)]
pub struct SystemEvaluation<const N: usize> {
pub cost: f64,
pub normal: [[f64; N]; N],
pub rhs: [f64; N],
}
pub trait SystemProblem<const N: usize>: CostProblem<N> {
fn evaluate_system(&mut self, x: &[f64; N]) -> Result<SystemEvaluation<N>, Self::Error>;
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum TerminationReason {
GradientTolerance,
StepTolerance,
CostTolerance,
MaxIterations,
DampingExhausted {
mu: f64,
},
InnerTrialsExhausted {
trials: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum CovarianceFailure {
SingularNormalMatrix,
}
#[derive(Clone, Debug)]
pub struct LMSolution<const N: usize> {
pub x: [f64; N],
pub covariance: Result<[[f64; N]; N], CovarianceFailure>,
pub cost: f64,
pub data_cost: f64,
pub accepted_step_qnorm: Option<f64>,
pub gradient_norm_scaled: f64,
pub iterations: usize,
pub n_cost_evals: usize,
pub n_rejected_trials: usize,
pub n_invalid_trials: usize,
pub n_accelerated_trials: usize,
pub mu_final: f64,
pub converged: bool,
pub reason: TerminationReason,
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ConfigDefect {
MaxIterationsZero,
MaxInnerTrialsZero,
TauNotPositive {
value: f64,
},
MuMaxNotPositive {
value: f64,
},
TauExceedsMuMax {
tau: f64,
mu_max: f64,
},
MinRelativeDecreaseNegative {
value: f64,
},
GtolNegative {
value: f64,
},
QtolNegative {
value: f64,
},
XtolNegative {
value: f64,
},
FtolNegative {
value: f64,
},
MaxConsecutiveInvalidZero,
AvmaxNotPositive {
value: f64,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum PriorDefect {
NonFiniteMean {
index: usize,
},
NonFiniteCovarianceInv {
row: usize,
col: usize,
},
NegativeDiagonal {
index: usize,
value: f64,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum EvaluationDefect {
Cost,
NegativeCost {
value: f64,
},
Residual {
index: usize,
},
JacobianEntry {
row: usize,
col: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum SystemDefect {
NonFiniteCost,
NonFiniteNormal {
row: usize,
col: usize,
},
NonFiniteRhs {
index: usize,
},
NegativeCost {
value: f64,
},
NegativeDiagonal {
index: usize,
value: f64,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LMError<E> {
InvalidConfig {
defect: ConfigDefect,
},
InvalidPrior {
defect: PriorDefect,
},
InvalidSystem {
iteration: usize,
defect: SystemDefect,
},
EmptyResiduals {
iteration: usize,
},
DimensionMismatch {
iteration: usize,
residuals: usize,
jacobian: usize,
},
ZeroDampingDiagonal {
iteration: usize,
},
InitialEvaluationFailed {
source: E,
},
InvalidEvaluation {
iteration: usize,
defect: EvaluationDefect,
},
PersistentInvalidTrials {
iteration: usize,
consecutive: usize,
last_source: Option<E>,
best_x: Vec<f64>,
best_cost: f64,
},
}
impl<E: std::fmt::Display> std::fmt::Display for LMError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidConfig { defect } => write!(f, "invalid configuration: {defect:?}"),
Self::InvalidPrior { defect } => write!(f, "invalid prior: {defect:?}"),
Self::InvalidSystem { iteration, defect } => {
write!(
f,
"invalid system evaluation at iteration {iteration}: {defect:?}"
)
}
Self::EmptyResiduals { iteration } => {
write!(
f,
"evaluation returned no residuals at iteration {iteration}"
)
}
Self::DimensionMismatch {
iteration,
residuals,
jacobian,
} => write!(
f,
"dimension mismatch at iteration {iteration}: {residuals} residuals vs \
{jacobian} Jacobian rows"
),
Self::ZeroDampingDiagonal { iteration } => write!(
f,
"scaling diagonal identically zero at iteration {iteration} (zero Jacobian, \
no prior): damping cannot regularize this system"
),
Self::InitialEvaluationFailed { source } => {
write!(f, "initial evaluation failed: {source}")
}
Self::InvalidEvaluation { iteration, defect } => {
write!(f, "invalid evaluation at iteration {iteration}: {defect:?}")
}
Self::PersistentInvalidTrials {
iteration,
consecutive,
best_cost,
..
} => write!(
f,
"{consecutive} consecutive invalid trial evaluations at iteration {iteration} \
(best retained cost {best_cost})"
),
}
}
}
impl<E: std::error::Error + 'static> std::error::Error for LMError<E> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InitialEvaluationFailed { source } => Some(source),
Self::PersistentInvalidTrials {
last_source: Some(source),
..
} => Some(source),
_ => None,
}
}
}
struct AssembledSystem<const N: usize> {
cost: f64,
data_cost: f64,
normal: [[f64; N]; N],
rhs: [f64; N],
jacobian: Option<Vec<[f64; N]>>,
}
enum AssembleFailure<E> {
Domain(E),
NonFinite(EvaluationDefect),
SystemNonFinite(SystemDefect),
Hard(HardDefect),
}
enum HardDefect {
EmptyResiduals,
DimensionMismatch { residuals: usize, jacobian: usize },
NegativeDiagonal { index: usize, value: f64 },
}
impl HardDefect {
fn into_error<E>(self, iteration: usize) -> LMError<E> {
match self {
Self::EmptyResiduals => LMError::EmptyResiduals { iteration },
Self::DimensionMismatch {
residuals,
jacobian,
} => LMError::DimensionMismatch {
iteration,
residuals,
jacobian,
},
Self::NegativeDiagonal { index, value } => LMError::InvalidSystem {
iteration,
defect: SystemDefect::NegativeDiagonal { index, value },
},
}
}
}
trait SystemSource<const N: usize> {
type Error: std::error::Error + 'static;
fn assemble(
&mut self,
x: &[f64; N],
) -> Result<AssembledSystem<N>, AssembleFailure<Self::Error>>;
fn trial_cost(&mut self, x: &[f64; N]) -> Result<f64, Self::Error>;
fn constrain(&mut self, x: &[f64; N], delta: &mut [f64; N]);
fn accepted(&mut self, x: &[f64; N]);
fn rejected(&mut self, x_trial: &[f64; N]);
fn second_directional_derivative(&mut self, x: &[f64; N], v: &[f64; N]) -> Option<Vec<f64>>;
}
struct ResidualSource<'a, P, const N: usize> {
problem: &'a mut P,
prior: Option<NLLSPrior<N>>,
}
impl<'a, P: ResidualProblem<N>, const N: usize> ResidualSource<'a, P, N> {
fn prior_penalty(&self, x: &[f64; N]) -> f64 {
let Some(p) = &self.prior else { return 0.0 };
let mut penalty = 0.0_f64;
for i in 0..N {
let mut row = 0.0_f64;
for j in 0..N {
row += p.covariance_inv[i][j] * (x[j] - p.mean[j]);
}
penalty += (x[i] - p.mean[i]) * row;
}
penalty
}
}
impl<'a, P: ResidualProblem<N>, const N: usize> SystemSource<N> for ResidualSource<'a, P, N> {
type Error = P::Error;
fn assemble(
&mut self,
x: &[f64; N],
) -> Result<AssembledSystem<N>, AssembleFailure<Self::Error>> {
let eval = self.problem.evaluate(x).map_err(AssembleFailure::Domain)?;
if eval.residuals.is_empty() {
return Err(AssembleFailure::Hard(HardDefect::EmptyResiduals));
}
if eval.residuals.len() != eval.jacobian.len() {
return Err(AssembleFailure::Hard(HardDefect::DimensionMismatch {
residuals: eval.residuals.len(),
jacobian: eval.jacobian.len(),
}));
}
if !eval.cost.is_finite() {
return Err(AssembleFailure::NonFinite(EvaluationDefect::Cost));
}
if eval.cost < 0.0 {
return Err(AssembleFailure::NonFinite(EvaluationDefect::NegativeCost {
value: eval.cost,
}));
}
for (i, r) in eval.residuals.iter().enumerate() {
if !r.is_finite() {
return Err(AssembleFailure::NonFinite(EvaluationDefect::Residual {
index: i,
}));
}
}
for (i, row) in eval.jacobian.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
if !v.is_finite() {
return Err(AssembleFailure::NonFinite(
EvaluationDefect::JacobianEntry { row: i, col: j },
));
}
}
}
let mut normal = [[0.0_f64; N]; N];
let mut rhs = [0.0_f64; N];
for (r_i, j_i) in eval.residuals.iter().zip(eval.jacobian.iter()) {
for j in 0..N {
for k in 0..N {
normal[j][k] += j_i[j] * j_i[k];
}
rhs[j] -= j_i[j] * r_i;
}
}
let mut normal = mat_symmetrize(&normal);
if let Some(p) = &self.prior {
for i in 0..N {
for j in 0..N {
normal[i][j] += p.covariance_inv[i][j];
}
let mut delta_weighted = 0.0_f64;
for j in 0..N {
delta_weighted += p.covariance_inv[i][j] * (x[j] - p.mean[j]);
}
rhs[i] -= delta_weighted;
}
}
let data_cost = eval.cost;
let cost = data_cost + self.prior_penalty(x);
if !cost.is_finite() {
return Err(AssembleFailure::NonFinite(EvaluationDefect::Cost));
}
if cost < 0.0 {
return Err(AssembleFailure::NonFinite(EvaluationDefect::NegativeCost {
value: cost,
}));
}
Ok(AssembledSystem {
cost,
data_cost,
normal,
rhs,
jacobian: Some(eval.jacobian),
})
}
fn trial_cost(&mut self, x: &[f64; N]) -> Result<f64, Self::Error> {
let data = self.problem.evaluate_cost(x)?;
Ok(data + self.prior_penalty(x))
}
fn constrain(&mut self, x: &[f64; N], delta: &mut [f64; N]) {
self.problem.constrain_step(x, delta);
}
fn accepted(&mut self, x: &[f64; N]) {
self.problem.on_step_accepted(x);
}
fn rejected(&mut self, x_trial: &[f64; N]) {
self.problem.on_step_rejected(x_trial);
}
fn second_directional_derivative(&mut self, x: &[f64; N], v: &[f64; N]) -> Option<Vec<f64>> {
self.problem.second_directional_derivative(x, v)
}
}
struct DirectSource<'a, P, const N: usize> {
problem: &'a mut P,
}
impl<'a, P: SystemProblem<N>, const N: usize> SystemSource<N> for DirectSource<'a, P, N> {
type Error = P::Error;
fn assemble(
&mut self,
x: &[f64; N],
) -> Result<AssembledSystem<N>, AssembleFailure<Self::Error>> {
let sys = self
.problem
.evaluate_system(x)
.map_err(AssembleFailure::Domain)?;
if !sys.cost.is_finite() {
return Err(AssembleFailure::SystemNonFinite(
SystemDefect::NonFiniteCost,
));
}
if sys.cost < 0.0 {
return Err(AssembleFailure::SystemNonFinite(
SystemDefect::NegativeCost { value: sys.cost },
));
}
for i in 0..N {
for j in 0..N {
if !sys.normal[i][j].is_finite() {
return Err(AssembleFailure::SystemNonFinite(
SystemDefect::NonFiniteNormal { row: i, col: j },
));
}
}
if !sys.rhs[i].is_finite() {
return Err(AssembleFailure::SystemNonFinite(
SystemDefect::NonFiniteRhs { index: i },
));
}
}
for i in 0..N {
if sys.normal[i][i] < 0.0 {
return Err(AssembleFailure::Hard(HardDefect::NegativeDiagonal {
index: i,
value: sys.normal[i][i],
}));
}
}
Ok(AssembledSystem {
cost: sys.cost,
data_cost: sys.cost,
normal: mat_symmetrize(&sys.normal),
rhs: sys.rhs,
jacobian: None,
})
}
fn trial_cost(&mut self, x: &[f64; N]) -> Result<f64, Self::Error> {
self.problem.evaluate_cost(x)
}
fn constrain(&mut self, x: &[f64; N], delta: &mut [f64; N]) {
self.problem.constrain_step(x, delta);
}
fn accepted(&mut self, x: &[f64; N]) {
self.problem.on_step_accepted(x);
}
fn rejected(&mut self, x_trial: &[f64; N]) {
self.problem.on_step_rejected(x_trial);
}
fn second_directional_derivative(&mut self, x: &[f64; N], v: &[f64; N]) -> Option<Vec<f64>> {
self.problem.second_directional_derivative(x, v)
}
}
pub fn solve<P: ResidualProblem<N>, const N: usize>(
problem: &mut P,
x0: [f64; N],
config: &LMConfig,
prior: Option<&NLLSPrior<N>>,
) -> Result<LMSolution<N>, LMError<P::Error>> {
validate_config(config)?;
let prior = match prior {
Some(p) => {
validate_prior(p)?;
Some(NLLSPrior {
mean: p.mean,
covariance_inv: mat_symmetrize(&p.covariance_inv),
})
}
None => None,
};
let mut source = ResidualSource { problem, prior };
solve_core(&mut source, x0, config)
}
pub fn solve_system<P: SystemProblem<N>, const N: usize>(
problem: &mut P,
x0: [f64; N],
config: &LMConfig,
) -> Result<LMSolution<N>, LMError<P::Error>> {
validate_config(config)?;
let mut source = DirectSource { problem };
solve_core(&mut source, x0, config)
}
pub fn solve_nlls<E, FEval, FCost, const N: usize>(
eval: FEval,
cost: FCost,
x0: [f64; N],
config: &LMConfig,
prior: Option<&NLLSPrior<N>>,
) -> Result<LMSolution<N>, LMError<E>>
where
E: std::error::Error + 'static,
FEval: FnMut(&[f64; N]) -> Result<NLLSEvaluation<N>, E>,
FCost: FnMut(&[f64; N]) -> Result<f64, E>,
{
struct ClosureProblem<FEval, FCost> {
eval: FEval,
cost: FCost,
}
impl<E, FEval, FCost, const N: usize> CostProblem<N> for ClosureProblem<FEval, FCost>
where
E: std::error::Error + 'static,
FEval: FnMut(&[f64; N]) -> Result<NLLSEvaluation<N>, E>,
FCost: FnMut(&[f64; N]) -> Result<f64, E>,
{
type Error = E;
fn evaluate_cost(&mut self, x: &[f64; N]) -> Result<f64, E> {
(self.cost)(x)
}
}
impl<E, FEval, FCost, const N: usize> ResidualProblem<N> for ClosureProblem<FEval, FCost>
where
E: std::error::Error + 'static,
FEval: FnMut(&[f64; N]) -> Result<NLLSEvaluation<N>, E>,
FCost: FnMut(&[f64; N]) -> Result<f64, E>,
{
fn evaluate(&mut self, x: &[f64; N]) -> Result<NLLSEvaluation<N>, E> {
(self.eval)(x)
}
}
let mut problem = ClosureProblem { eval, cost };
solve(&mut problem, x0, config, prior)
}
fn validate_config<E>(config: &LMConfig) -> Result<(), LMError<E>> {
let defect = if config.max_iterations == 0 {
Some(ConfigDefect::MaxIterationsZero)
} else if config.max_inner_trials == 0 {
Some(ConfigDefect::MaxInnerTrialsZero)
} else if !(config.tau.is_finite() && config.tau > 0.0) {
Some(ConfigDefect::TauNotPositive { value: config.tau })
} else if !(config.mu_max.is_finite() && config.mu_max > 0.0) {
Some(ConfigDefect::MuMaxNotPositive {
value: config.mu_max,
})
} else if config.tau > config.mu_max {
Some(ConfigDefect::TauExceedsMuMax {
tau: config.tau,
mu_max: config.mu_max,
})
} else if !(config.min_relative_decrease.is_finite() && config.min_relative_decrease >= 0.0) {
Some(ConfigDefect::MinRelativeDecreaseNegative {
value: config.min_relative_decrease,
})
} else if !(config.gtol.is_finite() && config.gtol >= 0.0) {
Some(ConfigDefect::GtolNegative { value: config.gtol })
} else if !(config.qtol.is_finite() && config.qtol >= 0.0) {
Some(ConfigDefect::QtolNegative { value: config.qtol })
} else if !(config.xtol.is_finite() && config.xtol >= 0.0) {
Some(ConfigDefect::XtolNegative { value: config.xtol })
} else if !(config.ftol.is_finite() && config.ftol >= 0.0) {
Some(ConfigDefect::FtolNegative { value: config.ftol })
} else if config.max_consecutive_invalid == 0 {
Some(ConfigDefect::MaxConsecutiveInvalidZero)
} else if !(config.avmax.is_finite() && config.avmax > 0.0) {
Some(ConfigDefect::AvmaxNotPositive {
value: config.avmax,
})
} else {
None
};
match defect {
Some(defect) => Err(LMError::InvalidConfig { defect }),
None => Ok(()),
}
}
fn validate_prior<E, const N: usize>(prior: &NLLSPrior<N>) -> Result<(), LMError<E>> {
for (i, m) in prior.mean.iter().enumerate() {
if !m.is_finite() {
return Err(LMError::InvalidPrior {
defect: PriorDefect::NonFiniteMean { index: i },
});
}
}
for i in 0..N {
for j in 0..N {
if !prior.covariance_inv[i][j].is_finite() {
return Err(LMError::InvalidPrior {
defect: PriorDefect::NonFiniteCovarianceInv { row: i, col: j },
});
}
}
if prior.covariance_inv[i][i] < 0.0 {
return Err(LMError::InvalidPrior {
defect: PriorDefect::NegativeDiagonal {
index: i,
value: prior.covariance_inv[i][i],
},
});
}
}
Ok(())
}
fn update_scaling<const N: usize>(d: &mut [f64; N], normal: &[[f64; N]; N]) -> f64 {
let mut d_max = 0.0_f64;
for j in 0..N {
let col = normal[j][j].sqrt();
if col > d[j] {
d[j] = col;
}
if d[j] > d_max {
d_max = d[j];
}
}
d_max
}
#[inline]
fn effective_scale(d_j: f64, d_max: f64) -> f64 {
if d_j > 0.0 { d_j } else { D_FLOOR_REL * d_max }
}
fn damped_factor<const N: usize>(
normal: &[[f64; N]; N],
d: &[f64; N],
d_max: f64,
mu: f64,
) -> Option<[[f64; N]; N]> {
let mut b = [[0.0_f64; N]; N];
for i in 0..N {
let di = effective_scale(d[i], d_max);
for j in 0..N {
b[i][j] = normal[i][j] / (di * effective_scale(d[j], d_max));
if !b[i][j].is_finite() {
return None;
}
}
b[i][i] += mu;
}
mat_cholesky(&b)
}
fn solve_with_factor<const N: usize>(
l: &[[f64; N]; N],
rhs: &[f64; N],
d: &[f64; N],
d_max: f64,
) -> Option<[f64; N]> {
let mut scaled_rhs = [0.0_f64; N];
for i in 0..N {
scaled_rhs[i] = rhs[i] / effective_scale(d[i], d_max);
}
let mut y = [0.0_f64; N];
for i in 0..N {
let mut sum = scaled_rhs[i];
for k in 0..i {
sum -= l[i][k] * y[k];
}
y[i] = sum / l[i][i];
}
let mut hp = [0.0_f64; N];
for i in (0..N).rev() {
let mut sum = y[i];
for k in (i + 1)..N {
sum -= l[k][i] * hp[k];
}
hp[i] = sum / l[i][i];
}
let mut h = [0.0_f64; N];
for i in 0..N {
h[i] = hp[i] / effective_scale(d[i], d_max);
if !h[i].is_finite() {
return None;
}
}
Some(h)
}
fn solve_damped<const N: usize>(
normal: &[[f64; N]; N],
rhs: &[f64; N],
d: &[f64; N],
d_max: f64,
mu: f64,
) -> Option<[f64; N]> {
let l = damped_factor(normal, d, d_max, mu)?;
solve_with_factor(&l, rhs, d, d_max)
}
fn predicted_reduction<const N: usize>(
h: &[f64; N],
normal: &[[f64; N]; N],
rhs: &[f64; N],
) -> f64 {
let mut hg = 0.0_f64;
let mut hah = 0.0_f64;
for i in 0..N {
hg += h[i] * rhs[i];
let mut row = 0.0_f64;
for j in 0..N {
row += normal[i][j] * h[j];
}
hah += h[i] * row;
}
2.0 * hg - hah
}
fn quadratic_form<const N: usize>(h: &[f64; N], normal: &[[f64; N]; N]) -> f64 {
let mut q = 0.0_f64;
for i in 0..N {
let mut row = 0.0_f64;
for j in 0..N {
row += normal[i][j] * h[j];
}
q += h[i] * row;
}
q
}
fn scaled_norm<const N: usize>(v: &[f64; N], d: &[f64; N]) -> f64 {
let mut s = 0.0_f64;
for j in 0..N {
let t = d[j] * v[j];
s += t * t;
}
s.sqrt()
}
fn covariance<const N: usize>(
normal: &[[f64; N]; N],
d: &[f64; N],
d_max: f64,
) -> Result<[[f64; N]; N], CovarianceFailure> {
let mut b = [[0.0_f64; N]; N];
for i in 0..N {
let di = effective_scale(d[i], d_max);
for j in 0..N {
b[i][j] = normal[i][j] / (di * effective_scale(d[j], d_max));
}
}
let b_inv = mat_inv(&b).ok_or(CovarianceFailure::SingularNormalMatrix)?;
let mut cov = [[0.0_f64; N]; N];
for i in 0..N {
let di = effective_scale(d[i], d_max);
for j in 0..N {
cov[i][j] = b_inv[i][j] / (di * effective_scale(d[j], d_max));
}
}
let cov = mat_symmetrize(&cov);
for i in 0..N {
for j in 0..N {
if !cov[i][j].is_finite() {
return Err(CovarianceFailure::SingularNormalMatrix);
}
}
}
Ok(cov)
}
#[inline]
fn escalate_mu(mu: &mut f64, nu: &mut f64, mu_max: f64) -> Option<TerminationReason> {
if *mu < MU_SEED {
*mu = MU_SEED;
}
*mu *= *nu;
*nu *= 2.0;
if *mu > mu_max {
Some(TerminationReason::DampingExhausted { mu: *mu })
} else {
None
}
}
fn persistent_invalid_trials<E, const N: usize>(
iteration: usize,
consecutive: usize,
last_source: Option<E>,
x: &[f64; N],
best_cost: f64,
) -> LMError<E> {
let mut best_x = Vec::with_capacity(N);
best_x.extend_from_slice(x);
LMError::PersistentInvalidTrials {
iteration,
consecutive,
last_source,
best_x,
best_cost,
}
}
struct AcceptedStep {
qnorm: f64,
clamped: bool,
prev_cost: f64,
pred: f64,
actred: f64,
}
fn solve_core<S: SystemSource<N>, const N: usize>(
source: &mut S,
x0: [f64; N],
config: &LMConfig,
) -> Result<LMSolution<N>, LMError<S::Error>> {
let mut x = x0;
let mut sys = match source.assemble(&x) {
Ok(s) => s,
Err(AssembleFailure::Domain(e)) => {
return Err(LMError::InitialEvaluationFailed { source: e });
}
Err(AssembleFailure::NonFinite(defect)) => {
return Err(LMError::InvalidEvaluation {
iteration: 0,
defect,
});
}
Err(AssembleFailure::SystemNonFinite(defect)) => {
return Err(LMError::InvalidSystem {
iteration: 0,
defect,
});
}
Err(AssembleFailure::Hard(h)) => return Err(h.into_error(0)),
};
let mut d = [0.0_f64; N];
let mut d_max = update_scaling(&mut d, &sys.normal);
if d_max == 0.0 {
return Err(LMError::ZeroDampingDiagonal { iteration: 0 });
}
source.accepted(&x);
let mut mu = {
let mut m = 0.0_f64;
for j in 0..N {
let dj = effective_scale(d[j], d_max);
let s = sys.normal[j][j] / (dj * dj);
if s > m {
m = s;
}
}
config.tau * m
};
let mut nu = 2.0_f64;
let mut last_accepted: Option<AcceptedStep> = None;
let mut consecutive_invalid = 0usize;
let mut last_invalid_source: Option<S::Error> = None;
let mut iterations = 0usize;
let mut n_cost_evals = 0usize;
let mut n_rejected_trials = 0usize;
let mut n_invalid_trials = 0usize;
let mut n_accelerated_trials = 0usize;
let mut converged = false;
let mut reason = TerminationReason::MaxIterations;
'outer: for iteration in 1..=config.max_iterations {
iterations = iteration;
if config.gtol > 0.0 {
let sqrt_cost = sys.cost.sqrt();
let mut pass = sqrt_cost.is_finite();
for j in 0..N {
if sys.rhs[j].abs() > config.gtol * sys.normal[j][j].sqrt() * sqrt_cost {
pass = false;
break;
}
}
if pass {
converged = true;
reason = TerminationReason::GradientTolerance;
break 'outer;
}
}
if let Some(h_gn) = solve_damped(&sys.normal, &sys.rhs, &d, d_max, 0.0) {
let q_gn = quadratic_form(&h_gn, &sys.normal);
let q_pass = config.qtol > 0.0 && q_gn <= config.qtol;
let x_pass = config.xtol > 0.0
&& scaled_norm(&h_gn, &d) <= config.xtol * (scaled_norm(&x, &d) + config.xtol);
if q_pass || x_pass {
converged = true;
reason = TerminationReason::StepTolerance;
break 'outer;
}
if let Some(acc) = &last_accepted
&& !acc.clamped
&& config.ftol > 0.0
&& acc.prev_cost > 0.0
{
let threshold = config.ftol * acc.prev_cost;
let ratio_ok = acc.pred > 0.0 && acc.actred <= 2.0 * acc.pred;
let gn_exhausted =
predicted_reduction(&h_gn, &sys.normal, &sys.rhs) <= config.ftol * sys.cost;
if acc.actred.abs() <= threshold
&& acc.pred <= threshold
&& ratio_ok
&& gn_exhausted
{
converged = true;
reason = TerminationReason::CostTolerance;
break 'outer;
}
}
}
let mut accepted_this_iteration = false;
for _trial in 0..config.max_inner_trials {
let velocity = damped_factor(&sys.normal, &d, d_max, mu)
.and_then(|l| solve_with_factor(&l, &sys.rhs, &d, d_max).map(|h| (l, h)));
let Some((factor, h_natural)) = velocity else {
n_rejected_trials += 1;
if let Some(r) = escalate_mu(&mut mu, &mut nu, config.mu_max) {
reason = r;
break 'outer;
}
continue;
};
let mut h = h_natural;
if config.geodesic_acceleration
&& let Some(jac) = sys.jacobian.as_ref()
&& let Some(w) = source.second_directional_derivative(&x, &h_natural)
{
if w.len() != jac.len() {
return Err(LMError::DimensionMismatch {
iteration,
residuals: w.len(),
jacobian: jac.len(),
});
}
let mut rhs_a = [0.0_f64; N];
for (w_i, row) in w.iter().zip(jac.iter()) {
for j in 0..N {
rhs_a[j] -= row[j] * w_i;
}
}
if let Some(a) = solve_with_factor(&factor, &rhs_a, &d, d_max) {
let v_norm = scaled_norm(&h_natural, &d);
let a_norm = scaled_norm(&a, &d);
if a_norm <= config.avmax * v_norm {
for j in 0..N {
h[j] += 0.5 * a[j];
}
n_accelerated_trials += 1;
} else {
n_rejected_trials += 1;
if let Some(r) = escalate_mu(&mut mu, &mut nu, config.mu_max) {
reason = r;
break 'outer;
}
continue;
}
}
}
let h_unclamped = h;
source.constrain(&x, &mut h);
let clamped = h != h_unclamped;
let pred = if clamped {
predicted_reduction(&h, &sys.normal, &sys.rhs)
} else {
predicted_reduction(&h_natural, &sys.normal, &sys.rhs)
};
let mut x_trial = [0.0_f64; N];
for i in 0..N {
x_trial[i] = x[i] + h[i];
}
n_cost_evals += 1;
let trial_result = source.trial_cost(&x_trial);
let cost_trial = match trial_result {
Ok(c) if c.is_finite() => Some(c),
Ok(_) => {
n_invalid_trials += 1;
consecutive_invalid += 1;
None
}
Err(e) => {
n_invalid_trials += 1;
consecutive_invalid += 1;
last_invalid_source = Some(e);
None
}
};
if consecutive_invalid >= config.max_consecutive_invalid {
source.rejected(&x_trial);
return Err(persistent_invalid_trials(
iteration,
consecutive_invalid,
last_invalid_source,
&x,
sys.cost,
));
}
let accept = match cost_trial {
Some(c) => pred > 0.0 && (sys.cost - c) > config.min_relative_decrease * pred,
None => false,
};
if !accept {
if cost_trial.is_some() {
n_rejected_trials += 1;
consecutive_invalid = 0;
last_invalid_source = None;
}
source.rejected(&x_trial);
if let Some(r) = escalate_mu(&mut mu, &mut nu, config.mu_max) {
reason = r;
break 'outer;
}
continue;
}
let cost_trial = cost_trial.expect("accepted trial has a finite cost");
let assembled = source.assemble(&x_trial);
match assembled {
Ok(new_sys) if new_sys.cost < sys.cost => {
let rho = (sys.cost - cost_trial) / pred;
let t = 2.0 * rho - 1.0;
let shrink = 1.0 - t * t * t;
let factor = if shrink > 1.0 / 3.0 {
shrink
} else {
1.0 / 3.0
};
mu *= factor;
if mu > config.mu_max {
mu = config.mu_max;
}
nu = 2.0;
consecutive_invalid = 0;
last_invalid_source = None;
source.accepted(&x_trial);
last_accepted = Some(AcceptedStep {
qnorm: quadratic_form(&h, &sys.normal),
clamped,
prev_cost: sys.cost,
pred,
actred: sys.cost - new_sys.cost,
});
x = x_trial;
sys = new_sys;
accepted_this_iteration = true;
}
Err(AssembleFailure::Hard(hard)) => {
source.rejected(&x_trial);
return Err(hard.into_error(iteration));
}
other => {
let source_err = match other {
Err(AssembleFailure::Domain(e)) => Some(e),
_ => None,
};
n_invalid_trials += 1;
consecutive_invalid += 1;
if let Some(e) = source_err {
last_invalid_source = Some(e);
}
if consecutive_invalid >= config.max_consecutive_invalid {
source.rejected(&x_trial);
return Err(persistent_invalid_trials(
iteration,
consecutive_invalid,
last_invalid_source,
&x,
sys.cost,
));
}
source.rejected(&x_trial);
if let Some(r) = escalate_mu(&mut mu, &mut nu, config.mu_max) {
reason = r;
break 'outer;
}
continue;
}
}
if accepted_this_iteration {
break;
}
}
if !accepted_this_iteration {
reason = TerminationReason::InnerTrialsExhausted {
trials: config.max_inner_trials,
};
break 'outer;
}
d_max = update_scaling(&mut d, &sys.normal);
}
let gradient_norm_scaled = {
let mut m = 0.0_f64;
for j in 0..N {
let s = sys.rhs[j].abs() / effective_scale(d[j], d_max);
if s > m {
m = s;
}
}
m
};
Ok(LMSolution {
x,
covariance: covariance(&sys.normal, &d, d_max),
cost: sys.cost,
data_cost: sys.data_cost,
accepted_step_qnorm: last_accepted.as_ref().map(|a| a.qnorm),
gradient_norm_scaled,
iterations,
n_cost_evals,
n_rejected_trials,
n_invalid_trials,
n_accelerated_trials,
mu_final: mu,
converged,
reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct TestError(&'static str);
impl std::fmt::Display for TestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for TestError {}
struct Tracked<F> {
f: F,
accepted: Vec<Vec<f64>>,
rejected: Vec<Vec<f64>>,
full_evals: Vec<f64>,
}
impl<F> Tracked<F> {
fn new(f: F) -> Self {
Self {
f,
accepted: Vec::new(),
rejected: Vec::new(),
full_evals: Vec::new(),
}
}
}
impl<F, const N: usize> CostProblem<N> for Tracked<F>
where
F: FnMut(&[f64; N]) -> (Vec<f64>, Vec<[f64; N]>),
{
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; N]) -> Result<f64, TestError> {
let (residuals, _) = (self.f)(x);
Ok(residuals.iter().map(|r| r * r).sum())
}
fn on_step_accepted(&mut self, x: &[f64; N]) {
self.accepted.push(x.to_vec());
}
fn on_step_rejected(&mut self, x_trial: &[f64; N]) {
self.rejected.push(x_trial.to_vec());
}
}
impl<F, const N: usize> ResidualProblem<N> for Tracked<F>
where
F: FnMut(&[f64; N]) -> (Vec<f64>, Vec<[f64; N]>),
{
fn evaluate(&mut self, x: &[f64; N]) -> Result<NLLSEvaluation<N>, TestError> {
let (residuals, jacobian) = (self.f)(x);
let cost = residuals.iter().map(|r| r * r).sum();
self.full_evals.push(cost);
Ok(NLLSEvaluation {
residuals,
jacobian,
cost,
})
}
}
fn config() -> LMConfig {
LMConfig::default()
}
type Resid1 = (Vec<f64>, Vec<[f64; 1]>);
fn overshoot_rational(x: &[f64; 1]) -> (Vec<f64>, Vec<[f64; 1]>) {
let d = x[0] - 5.0;
let q = 1.0 + d * d;
let r = d + 4.0 * d / q;
let j = 1.0 + 4.0 * (1.0 - d * d) / (q * q);
(vec![r], vec![[j]])
}
#[test]
fn test_linear_system() {
let mut p = Tracked::new(|x: &[f64; 2]| {
(vec![x[0] - 3.0, x[1] - 7.0], vec![[1.0, 0.0], [0.0, 1.0]])
});
let mut cfg = config();
cfg.xtol = 1e-14;
let sol = solve(&mut p, [0.0; 2], &cfg, None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 3.0).abs() < 1e-10, "x0={}", sol.x[0]);
assert!((sol.x[1] - 7.0).abs() < 1e-10, "x1={}", sol.x[1]);
let cov = sol.covariance.unwrap();
assert!((cov[0][0] - 1.0).abs() < 1e-10);
}
#[test]
fn test_overdetermined_linear() {
let xs = [0.0, 1.0, 2.0, 3.0];
let ys = [1.0, 3.0, 5.0, 7.0];
let mut p = Tracked::new(move |p: &[f64; 2]| {
let residuals: Vec<f64> = xs
.iter()
.zip(ys.iter())
.map(|(&x, &y)| p[0] * x + p[1] - y)
.collect();
let jacobian: Vec<[f64; 2]> = xs.iter().map(|&x| [x, 1.0]).collect();
(residuals, jacobian)
});
let sol = solve(&mut p, [0.0; 2], &config(), None).unwrap();
assert!(sol.converged);
assert!((sol.x[0] - 2.0).abs() < 1e-8, "a={}", sol.x[0]);
assert!((sol.x[1] - 1.0).abs() < 1e-8, "b={}", sol.x[1]);
}
fn rosenbrock(x: &[f64; 2]) -> (Vec<f64>, Vec<[f64; 2]>) {
(
vec![10.0 * (x[1] - x[0] * x[0]), 1.0 - x[0]],
vec![[-20.0 * x[0], 10.0], [-1.0, 0.0]],
)
}
#[test]
fn test_rosenbrock() {
let mut p = Tracked::new(rosenbrock);
let sol = solve(&mut p, [-1.0, 1.0], &config(), None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 1.0).abs() < 1e-6, "x0={}", sol.x[0]);
assert!((sol.x[1] - 1.0).abs() < 1e-6, "x1={}", sol.x[1]);
}
#[test]
fn test_circle_fit() {
let angles: [f64; 8] = [0.0, 0.7, 1.4, 2.1, 2.8, 3.5, 4.2, 4.9];
let data: Vec<(f64, f64)> = angles
.iter()
.map(|&a| (2.0 + 5.0 * a.cos(), 3.0 + 5.0 * a.sin()))
.collect();
let mut p = Tracked::new(move |p: &[f64; 3]| {
let (cx, cy, r) = (p[0], p[1], p[2]);
let mut residuals = Vec::new();
let mut jacobian = Vec::new();
for &(x, y) in &data {
let dx = x - cx;
let dy = y - cy;
let dist = (dx * dx + dy * dy).sqrt();
residuals.push(dist - r);
jacobian.push([-dx / dist, -dy / dist, -1.0]);
}
(residuals, jacobian)
});
let sol = solve(&mut p, [0.0, 0.0, 1.0], &config(), None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 2.0).abs() < 1e-7, "cx={}", sol.x[0]);
assert!((sol.x[1] - 3.0).abs() < 1e-7, "cy={}", sol.x[1]);
assert!((sol.x[2] - 5.0).abs() < 1e-7, "r={}", sol.x[2]);
}
#[test]
fn test_overshoot_converges_without_clamp() {
let mut p = Tracked::new(|x: &[f64; 1]| {
let d = x[0] - 5.0;
let r = (1.0 + d * d).ln();
let j = 2.0 * d / (1.0 + d * d);
(vec![r], vec![[j]])
});
let mut cfg = config();
cfg.max_iterations = 300;
let sol = solve(&mut p, [100.0], &cfg, None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 5.0).abs() < 1e-3, "x={}", sol.x[0]);
assert!(
sol.n_rejected_trials > 0,
"overshoot should exercise rejections"
);
}
#[test]
fn test_tau_insensitivity_sweep() {
let taus = [1e-8, 1e-6, 1e-4, 1e-3, 1e-1, 1.0, 1e2];
for &tau in &taus {
let mut cfg = config();
cfg.tau = tau;
cfg.max_iterations = 500;
let mut p = Tracked::new(rosenbrock);
let sol = solve(&mut p, [-1.0, 1.0], &cfg, None).unwrap();
assert!(sol.converged, "rosenbrock tau={tau}: {:?}", sol.reason);
assert!(
(sol.x[0] - 1.0).abs() < 1e-6 && (sol.x[1] - 1.0).abs() < 1e-6,
"rosenbrock tau={tau}: x={:?}",
sol.x
);
let mut p = Tracked::new(overshoot_rational);
let sol = solve(&mut p, [6.5], &cfg, None).unwrap();
assert!(sol.converged, "overshoot tau={tau}: {:?}", sol.reason);
assert!(
(sol.x[0] - 5.0).abs() < 1e-4,
"overshoot tau={tau}: x={}",
sol.x[0]
);
let mut p = Tracked::new(|x: &[f64; 1]| {
let d = x[0] - 5.0;
let r = (1.0 + d * d).ln();
let j = 2.0 * d / (1.0 + d * d);
(vec![r], vec![[j]])
});
let sol = solve(&mut p, [100.0], &cfg, None).unwrap();
assert!(sol.converged, "log-overshoot tau={tau}: {:?}", sol.reason);
assert!(
(sol.x[0] - 5.0).abs() < 1e-3,
"log-overshoot tau={tau}: x={}",
sol.x[0]
);
}
}
#[test]
fn test_prior_map_solution() {
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0] - 10.0], vec![[1.0]]));
let prior = NLLSPrior {
mean: [0.0],
covariance_inv: [[1.0]],
};
let sol = solve(&mut p, [0.0], &config(), Some(&prior)).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 5.0).abs() < 1e-8, "x={}", sol.x[0]);
let cov = sol.covariance.unwrap();
assert!((cov[0][0] - 0.5).abs() < 1e-10, "cov={}", cov[0][0]);
assert!((sol.cost - 50.0).abs() < 1e-6, "cost={}", sol.cost);
assert!(
(sol.data_cost - 25.0).abs() < 1e-6,
"data={}",
sol.data_cost
);
}
#[test]
fn test_prior_penalty_in_acceptance() {
let mut p = Tracked::new(|x: &[f64; 1]| (vec![0.1 * (x[0] - 10.0)], vec![[0.1]]));
let prior = NLLSPrior {
mean: [0.0],
covariance_inv: [[1.0]],
};
let mut cfg = config();
cfg.xtol = 1e-13;
let sol = solve(&mut p, [10.0], &cfg, Some(&prior)).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
let expected = 0.1 / 1.01;
assert!(
(sol.x[0] - expected).abs() < 1e-6,
"x={} expected={expected}",
sol.x[0]
);
}
#[test]
fn test_prior_zero_jacobian_posterior_is_prior() {
let mut p = Tracked::new(|_x: &[f64; 1]| (vec![5.0], vec![[0.0]]));
let prior = NLLSPrior {
mean: [2.0],
covariance_inv: [[1.0]],
};
let sol = solve(&mut p, [0.0], &config(), Some(&prior)).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 2.0).abs() < 1e-8, "x={}", sol.x[0]);
let cov = sol.covariance.unwrap();
assert!((cov[0][0] - 1.0).abs() < 1e-8, "cov={}", cov[0][0]);
assert!(sol.cost.is_finite());
assert!((sol.data_cost - 25.0).abs() < 1e-10);
}
#[test]
fn test_initial_domain_error() {
struct Failing;
impl CostProblem<1> for Failing {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Err(TestError("cost"))
}
}
impl ResidualProblem<1> for Failing {
fn evaluate(&mut self, _x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Err(TestError("propagation failed"))
}
}
let err = solve(&mut Failing, [0.0], &config(), None).unwrap_err();
assert!(
matches!(err, LMError::InitialEvaluationFailed { .. }),
"{err:?}"
);
}
#[test]
fn test_nonfinite_initial_cost_is_error() {
struct InfCost;
impl CostProblem<1> for InfCost {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(f64::INFINITY)
}
}
impl ResidualProblem<1> for InfCost {
fn evaluate(&mut self, _x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![0.0],
jacobian: vec![[0.0]],
cost: f64::INFINITY,
})
}
}
let err = solve(&mut InfCost, [0.0], &config(), None).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidEvaluation {
iteration: 0,
defect: EvaluationDefect::Cost
}
),
"{err:?}"
);
}
#[test]
fn test_empty_residuals_and_dimension_mismatch() {
let mut p = Tracked::new(|_x: &[f64; 1]| (vec![], vec![]));
let err = solve(&mut p, [0.0], &config(), None).unwrap_err();
assert!(
matches!(err, LMError::EmptyResiduals { iteration: 0 }),
"{err:?}"
);
let mut p = Tracked::new(|_x: &[f64; 1]| (vec![1.0, 2.0], vec![[1.0]]));
let err = solve(&mut p, [0.0], &config(), None).unwrap_err();
assert!(
matches!(
err,
LMError::DimensionMismatch {
iteration: 0,
residuals: 2,
jacobian: 1
}
),
"{err:?}"
);
}
#[test]
fn test_zero_damping_diagonal() {
let mut p = Tracked::new(|_x: &[f64; 2]| (vec![1.0], vec![[0.0, 0.0]]));
let err = solve(&mut p, [0.0; 2], &config(), None).unwrap_err();
assert!(
matches!(err, LMError::ZeroDampingDiagonal { iteration: 0 }),
"{err:?}"
);
}
#[test]
fn test_invalid_config_each_field() {
let check = |cfg: LMConfig, want: fn(&ConfigDefect) -> bool| {
let mut p_local = Tracked::new(|x: &[f64; 1]| (vec![x[0]], vec![[1.0]]));
let err = solve(&mut p_local, [1.0], &cfg, None).unwrap_err();
match err {
LMError::InvalidConfig { defect } => assert!(want(&defect), "{defect:?}"),
other => panic!("expected InvalidConfig, got {other:?}"),
}
};
let mut c = config();
c.max_iterations = 0;
check(c, |d| matches!(d, ConfigDefect::MaxIterationsZero));
let mut c = config();
c.max_inner_trials = 0;
check(c, |d| matches!(d, ConfigDefect::MaxInnerTrialsZero));
let mut c = config();
c.tau = 0.0;
check(c, |d| matches!(d, ConfigDefect::TauNotPositive { .. }));
let mut c = config();
c.tau = f64::NAN;
check(c, |d| matches!(d, ConfigDefect::TauNotPositive { .. }));
let mut c = config();
c.mu_max = 0.0;
check(c, |d| matches!(d, ConfigDefect::MuMaxNotPositive { .. }));
let mut c = config();
c.min_relative_decrease = -1.0;
check(c, |d| {
matches!(d, ConfigDefect::MinRelativeDecreaseNegative { .. })
});
let mut c = config();
c.gtol = -1.0;
check(c, |d| matches!(d, ConfigDefect::GtolNegative { .. }));
let mut c = config();
c.qtol = -1.0;
check(c, |d| matches!(d, ConfigDefect::QtolNegative { .. }));
let mut c = config();
c.xtol = f64::INFINITY;
check(c, |d| matches!(d, ConfigDefect::XtolNegative { .. }));
let mut c = config();
c.ftol = -1.0;
check(c, |d| matches!(d, ConfigDefect::FtolNegative { .. }));
let mut c = config();
c.max_consecutive_invalid = 0;
check(c, |d| matches!(d, ConfigDefect::MaxConsecutiveInvalidZero));
}
#[test]
fn test_invalid_prior() {
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0]], vec![[1.0]]));
let prior = NLLSPrior {
mean: [f64::NAN],
covariance_inv: [[1.0]],
};
let err = solve(&mut p, [0.0], &config(), Some(&prior)).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidPrior {
defect: PriorDefect::NonFiniteMean { index: 0 }
}
),
"{err:?}"
);
let prior = NLLSPrior {
mean: [0.0],
covariance_inv: [[-1.0]],
};
let err = solve(&mut p, [0.0], &config(), Some(&prior)).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidPrior {
defect: PriorDefect::NegativeDiagonal { index: 0, .. }
}
),
"{err:?}"
);
}
#[test]
fn test_inf_trial_cost_recovers() {
struct Walled {
inner: Tracked<fn(&[f64; 1]) -> Resid1>,
}
impl CostProblem<1> for Walled {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
if x[0] > 50.0 {
return Ok(f64::INFINITY); }
self.inner.evaluate_cost(x)
}
}
impl ResidualProblem<1> for Walled {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.inner.evaluate(x)
}
}
fn log_overshoot(x: &[f64; 1]) -> (Vec<f64>, Vec<[f64; 1]>) {
let d = x[0] - 5.0;
let r = (1.0 + d * d).ln();
let j = 2.0 * d / (1.0 + d * d);
(vec![r], vec![[j]])
}
let mut p = Walled {
inner: Tracked::new(log_overshoot as fn(&[f64; 1]) -> _),
};
let mut cfg = config();
cfg.max_iterations = 300;
cfg.max_consecutive_invalid = 12;
let sol = solve(&mut p, [-100.0], &cfg, None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 5.0).abs() < 1e-3, "x={}", sol.x[0]);
assert!(sol.n_invalid_trials > 0, "wall should have been hit");
}
#[test]
fn test_persistent_invalid_trials() {
struct AlwaysFailsCost;
impl CostProblem<1> for AlwaysFailsCost {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Err(TestError("always fails"))
}
}
impl ResidualProblem<1> for AlwaysFailsCost {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let err = solve(&mut AlwaysFailsCost, [0.0], &config(), None).unwrap_err();
match err {
LMError::PersistentInvalidTrials {
consecutive,
last_source,
best_x,
best_cost,
..
} => {
assert_eq!(consecutive, 5);
assert_eq!(last_source, Some(TestError("always fails")));
assert_eq!(best_x, vec![0.0]);
assert!((best_cost - 9.0).abs() < 1e-12);
}
other => panic!("expected PersistentInvalidTrials, got {other:?}"),
}
}
#[test]
fn test_reason_gradient_tolerance() {
let mut cfg = config();
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0] - 3.0], vec![[1.0]]));
let sol = solve(&mut p, [0.0], &cfg, None).unwrap();
assert!(sol.converged);
assert_eq!(sol.reason, TerminationReason::GradientTolerance);
assert!((sol.x[0] - 3.0).abs() < 1e-10);
}
#[test]
fn test_reason_step_tolerance_at_stationary_start() {
let mut cfg = config();
cfg.gtol = 0.0;
cfg.ftol = 0.0;
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0] - 3.0], vec![[1.0]]));
let sol = solve(&mut p, [3.0], &cfg, None).unwrap();
assert!(sol.converged);
assert_eq!(sol.reason, TerminationReason::StepTolerance);
assert_eq!(sol.x[0], 3.0);
assert!(sol.accepted_step_qnorm.is_none());
}
#[test]
fn test_reason_max_iterations() {
let mut cfg = config();
cfg.max_iterations = 2;
let mut p = Tracked::new(rosenbrock);
let sol = solve(&mut p, [-1.2, 1.0], &cfg, None).unwrap();
assert!(!sol.converged);
assert_eq!(sol.reason, TerminationReason::MaxIterations);
assert_eq!(sol.iterations, 2);
}
#[test]
fn test_reason_damping_exhausted_on_adversarial_cost() {
struct Adversarial;
impl CostProblem<1> for Adversarial {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(10.0)
}
}
impl ResidualProblem<1> for Adversarial {
fn evaluate(&mut self, _x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![1.0],
jacobian: vec![[1.0]],
cost: 1.0,
})
}
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
let sol = solve(&mut Adversarial, [5.0], &cfg, None).unwrap();
assert!(!sol.converged);
assert!(
matches!(sol.reason, TerminationReason::DampingExhausted { .. }),
"{:?}",
sol.reason
);
assert_eq!(sol.x[0], 5.0, "iterate must not move on rejections");
}
#[test]
fn test_reason_inner_trials_exhausted() {
struct Adversarial;
impl CostProblem<1> for Adversarial {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(10.0)
}
}
impl ResidualProblem<1> for Adversarial {
fn evaluate(&mut self, _x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![1.0],
jacobian: vec![[1.0]],
cost: 1.0,
})
}
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
cfg.max_inner_trials = 3;
cfg.mu_max = 1e300;
let sol = solve(&mut Adversarial, [5.0], &cfg, None).unwrap();
assert!(!sol.converged);
assert_eq!(
sol.reason,
TerminationReason::InnerTrialsExhausted { trials: 3 }
);
}
#[test]
fn test_reason_cost_tolerance() {
let mut cfg = config();
cfg.gtol = 0.0;
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.max_iterations = 500;
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0] - 3.0, 10.0], vec![[1.0], [0.0]]));
let sol = solve(&mut p, [0.0], &cfg, None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert_eq!(sol.reason, TerminationReason::CostTolerance);
assert!((sol.x[0] - 3.0).abs() < 1e-2, "x={}", sol.x[0]);
}
#[test]
fn test_clamped_step_cannot_declare_convergence() {
struct Clamped {
inner: Tracked<fn(&[f64; 1]) -> Resid1>,
}
impl CostProblem<1> for Clamped {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
self.inner.evaluate_cost(x)
}
fn constrain_step(&mut self, _x: &[f64; 1], delta: &mut [f64; 1]) {
let cap = 0.01;
if delta[0].abs() > cap {
delta[0] = if delta[0] > 0.0 { cap } else { -cap };
}
}
}
impl ResidualProblem<1> for Clamped {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.inner.evaluate(x)
}
}
fn linear(x: &[f64; 1]) -> (Vec<f64>, Vec<[f64; 1]>) {
(vec![x[0] - 1000.0], vec![[1.0]])
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.ftol = 0.0;
cfg.xtol = 0.0;
cfg.qtol = 1.0; cfg.max_iterations = 50;
let mut p = Clamped {
inner: Tracked::new(linear as fn(&[f64; 1]) -> _),
};
let sol = solve(&mut p, [0.0], &cfg, None).unwrap();
assert!(
!sol.converged,
"clamp manufactured convergence: {:?}",
sol.reason
);
assert_eq!(sol.reason, TerminationReason::MaxIterations);
assert!((sol.x[0] - 0.5).abs() < 1e-9, "x={}", sol.x[0]);
}
#[test]
fn test_lifecycle_hooks_and_monotone_cost() {
let mut p = Tracked::new(overshoot_rational);
let sol = solve(&mut p, [6.5], &config(), None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 5.0).abs() < 1e-4);
assert!(!p.rejected.is_empty(), "expected genuine rejections");
assert!(!p.accepted.is_empty());
assert_eq!(p.accepted.last().unwrap()[0], sol.x[0]);
assert_eq!(p.full_evals.len(), p.accepted.len());
assert_eq!(p.accepted[0][0], 6.5, "x0 is committed first");
for w in p.full_evals.windows(2) {
assert!(w[1] < w[0], "cost not monotone: {:?}", p.full_evals);
}
for r in &p.rejected {
assert!(p.accepted.iter().all(|a| a != r));
}
}
#[test]
fn test_covariance_known_linear() {
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0] - 3.0], vec![[1.0]]));
let sol = solve(&mut p, [0.0], &config(), None).unwrap();
let cov = sol.covariance.unwrap();
assert!((cov[0][0] - 1.0).abs() < 1e-10);
}
#[test]
fn test_covariance_singular_is_explicit_never_zeros() {
let mut p = Tracked::new(|x: &[f64; 2]| (vec![x[0] - 3.0], vec![[1.0, 0.0]]));
let sol = solve(&mut p, [0.0, 0.0], &config(), None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 3.0).abs() < 1e-8, "x0={}", sol.x[0]);
assert_eq!(sol.x[1], 0.0);
assert_eq!(
sol.covariance.unwrap_err(),
CovarianceFailure::SingularNormalMatrix
);
}
#[test]
fn test_extreme_unit_mixing() {
let a = 3.0_f64;
let b = 7e9_f64;
let mut p = Tracked::new(move |x: &[f64; 2]| {
(
vec![
x[0] - a,
1e-10 * (x[1] - b),
1e-5 * (x[0] - a) + 1e-15 * (x[1] - b),
],
vec![[1.0, 0.0], [0.0, 1e-10], [1e-5, 1e-15]],
)
});
let mut cfg = config();
cfg.max_iterations = 200;
let sol = solve(&mut p, [0.0, 0.0], &cfg, None).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - a).abs() < 1e-6, "x0={}", sol.x[0]);
assert!((sol.x[1] - b).abs() / b < 1e-6, "x1={}", sol.x[1]);
assert!(sol.covariance.is_ok());
}
struct QuadSystem {
q: [[f64; 2]; 2],
a: [f64; 2],
c: f64,
}
impl QuadSystem {
fn cost(&self, x: &[f64; 2]) -> f64 {
let d = [x[0] - self.a[0], x[1] - self.a[1]];
let mut phi = self.c;
for i in 0..2 {
for j in 0..2 {
phi += d[i] * self.q[i][j] * d[j];
}
}
phi
}
}
impl CostProblem<2> for QuadSystem {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 2]) -> Result<f64, TestError> {
Ok(Self::cost(self, x))
}
}
impl SystemProblem<2> for QuadSystem {
fn evaluate_system(&mut self, x: &[f64; 2]) -> Result<SystemEvaluation<2>, TestError> {
let mut rhs = [0.0; 2];
for i in 0..2 {
for j in 0..2 {
rhs[i] += self.q[i][j] * (self.a[j] - x[j]);
}
}
Ok(SystemEvaluation {
cost: Self::cost(self, x),
normal: self.q,
rhs,
})
}
}
#[test]
fn test_solve_system_quadratic() {
let mut p = QuadSystem {
q: [[2.0, 0.0], [0.0, 8.0]],
a: [1.0, 2.0],
c: 3.0,
};
let sol = solve_system(&mut p, [10.0, -4.0], &config()).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
assert!((sol.x[0] - 1.0).abs() < 1e-8, "x0={}", sol.x[0]);
assert!((sol.x[1] - 2.0).abs() < 1e-8, "x1={}", sol.x[1]);
assert!((sol.cost - 3.0).abs() < 1e-8);
assert_eq!(sol.cost, sol.data_cost, "system path owns its objective");
let cov = sol.covariance.unwrap();
assert!((cov[0][0] - 0.5).abs() < 1e-10);
assert!((cov[1][1] - 0.125).abs() < 1e-10);
}
#[test]
fn test_invalid_system_negative_diagonal() {
struct BadSystem;
impl CostProblem<1> for BadSystem {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(1.0)
}
}
impl SystemProblem<1> for BadSystem {
fn evaluate_system(&mut self, _x: &[f64; 1]) -> Result<SystemEvaluation<1>, TestError> {
Ok(SystemEvaluation {
cost: 1.0,
normal: [[-1.0]],
rhs: [0.0],
})
}
}
let err = solve_system(&mut BadSystem, [0.0], &config()).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidSystem {
iteration: 0,
defect: SystemDefect::NegativeDiagonal { index: 0, .. }
}
),
"{err:?}"
);
}
#[test]
fn test_invalid_system_nonfinite() {
struct NanSystem;
impl CostProblem<1> for NanSystem {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(1.0)
}
}
impl SystemProblem<1> for NanSystem {
fn evaluate_system(&mut self, _x: &[f64; 1]) -> Result<SystemEvaluation<1>, TestError> {
Ok(SystemEvaluation {
cost: 1.0,
normal: [[f64::NAN]],
rhs: [0.0],
})
}
}
let err = solve_system(&mut NanSystem, [0.0], &config()).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidSystem {
iteration: 0,
defect: SystemDefect::NonFiniteNormal { row: 0, col: 0 }
}
),
"{err:?}"
);
}
#[test]
fn test_exact_solution_on_linear() {
let xs = [0.0, 1.0, 2.0, 3.0];
let ys = [1.0, 3.0, 5.0, 7.0];
let mut p = Tracked::new(move |p: &[f64; 2]| {
let residuals: Vec<f64> = xs
.iter()
.zip(ys.iter())
.map(|(&x, &y)| p[0] * x + p[1] - y)
.collect();
let jacobian: Vec<[f64; 2]> = xs.iter().map(|&x| [x, 1.0]).collect();
(residuals, jacobian)
});
let mut cfg = config();
cfg.xtol = 1e-14;
let solution = solve(&mut p, [0.0; 2], &cfg, None).unwrap();
assert!(solution.converged);
assert!((solution.x[0] - 2.0).abs() < 1e-9);
assert!((solution.x[1] - 1.0).abs() < 1e-9);
}
#[test]
fn test_mu_starved_accepted_step_is_not_convergence() {
fn stiff_cost(x: f64) -> f64 {
if x.abs() < 1e-7 {
1e6 - 1e-3 * (1e-7 - x.abs()) / 1e-7
} else {
1e6 + 1.0
}
}
struct StiffValley;
impl CostProblem<1> for StiffValley {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok(stiff_cost(x[0]))
}
}
impl ResidualProblem<1> for StiffValley {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 1000.0],
jacobian: vec![[1.0]],
cost: stiff_cost(x[0]),
})
}
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
cfg.qtol = 1.0; cfg.max_iterations = 30;
let sol = solve(&mut StiffValley, [0.0], &cfg, None).unwrap();
assert_ne!(sol.reason, TerminationReason::StepTolerance, "{sol:?}");
assert_ne!(sol.reason, TerminationReason::CostTolerance, "{sol:?}");
assert!(!sol.converged, "{:?}", sol.reason);
assert!(sol.x[0].abs() < 1.0, "stayed near start: {}", sol.x[0]);
}
#[test]
fn test_rollback_on_inconsistent_full_evaluation() {
struct Inconsistent {
full_calls: usize,
committed: Vec<f64>,
}
impl CostProblem<1> for Inconsistent {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok((x[0] - 3.0) * (x[0] - 3.0))
}
fn on_step_accepted(&mut self, x: &[f64; 1]) {
self.committed.push(x[0]);
}
}
impl ResidualProblem<1> for Inconsistent {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.full_calls += 1;
if self.full_calls == 1 {
return Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
});
}
Ok(NLLSEvaluation {
residuals: vec![100.0],
jacobian: vec![[1.0]],
cost: 10000.0,
})
}
}
let mut p = Inconsistent {
full_calls: 0,
committed: Vec::new(),
};
let err = solve(&mut p, [0.0], &config(), None).unwrap_err();
match err {
LMError::PersistentInvalidTrials {
best_x, best_cost, ..
} => {
assert_eq!(best_x, vec![0.0], "iterate must not move");
assert!((best_cost - 9.0).abs() < 1e-12);
}
other => panic!("expected PersistentInvalidTrials, got {other:?}"),
}
assert_eq!(
p.committed,
vec![0.0],
"only x0 may commit; rolled-back acceptances must not"
);
}
#[test]
fn test_pred_nonpositive_forces_rejection() {
struct Teleport {
inner: Tracked<fn(&[f64; 1]) -> Resid1>,
}
impl CostProblem<1> for Teleport {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
self.inner.evaluate_cost(x)
}
fn constrain_step(&mut self, x: &[f64; 1], delta: &mut [f64; 1]) {
delta[0] = -2.0 - x[0];
}
}
impl ResidualProblem<1> for Teleport {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.inner.evaluate(x)
}
}
fn bimodal(x: &[f64; 1]) -> Resid1 {
(vec![x[0] * x[0] - 4.0], vec![[2.0 * x[0]]])
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.ftol = 0.0;
cfg.xtol = 0.0;
cfg.qtol = 0.0;
cfg.max_iterations = 3;
let mut p = Teleport {
inner: Tracked::new(bimodal as fn(&[f64; 1]) -> _),
};
let sol = solve(&mut p, [3.0], &cfg, None).unwrap();
assert_eq!(sol.x[0], 3.0, "model-inconsistent step was accepted");
assert!(!sol.converged);
assert!(sol.n_rejected_trials > 0);
assert!(p.inner.accepted.is_empty());
}
#[test]
fn test_nan_trial_cost_is_invalid() {
struct NanCost;
impl CostProblem<1> for NanCost {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(f64::NAN)
}
}
impl ResidualProblem<1> for NanCost {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let err = solve(&mut NanCost, [0.0], &config(), None).unwrap_err();
match err {
LMError::PersistentInvalidTrials {
consecutive,
last_source,
..
} => {
assert_eq!(consecutive, 5);
assert_eq!(last_source, None, "NaN carries no domain error");
}
other => panic!("expected PersistentInvalidTrials, got {other:?}"),
}
}
#[test]
fn test_negative_cost_residual_path_is_error() {
for bad in [-1.0, -1e-30] {
struct NegCost(f64);
impl CostProblem<1> for NegCost {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(self.0)
}
}
impl ResidualProblem<1> for NegCost {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 1000.0],
jacobian: vec![[1.0]],
cost: self.0,
})
}
}
let err = solve(&mut NegCost(bad), [0.0], &config(), None).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidEvaluation {
iteration: 0,
defect: EvaluationDefect::NegativeCost { .. }
}
),
"cost={bad}: {err:?}"
);
}
}
#[test]
fn test_negative_cost_system_path_is_error() {
for bad in [-5.0, -1e-30] {
struct NegSystem(f64);
impl CostProblem<1> for NegSystem {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(self.0)
}
}
impl SystemProblem<1> for NegSystem {
fn evaluate_system(
&mut self,
_x: &[f64; 1],
) -> Result<SystemEvaluation<1>, TestError> {
Ok(SystemEvaluation {
cost: self.0,
normal: [[1.0]],
rhs: [1000.0],
})
}
}
let err = solve_system(&mut NegSystem(bad), [0.0], &config()).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidSystem {
iteration: 0,
defect: SystemDefect::NegativeCost { .. }
}
),
"cost={bad}: {err:?}"
);
}
}
#[test]
fn test_nonfinite_residual_and_jacobian_entries_are_errors() {
struct NanResidual;
impl CostProblem<1> for NanResidual {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(1.0)
}
}
impl ResidualProblem<1> for NanResidual {
fn evaluate(&mut self, _x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![f64::NAN],
jacobian: vec![[0.0]],
cost: 1.0,
})
}
}
let err = solve(&mut NanResidual, [0.0], &config(), None).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidEvaluation {
iteration: 0,
defect: EvaluationDefect::Residual { index: 0 }
}
),
"{err:?}"
);
let mut p = Tracked::new(|_x: &[f64; 1]| (vec![1.0], vec![[f64::INFINITY]]));
let err = solve(&mut p, [0.0], &config(), None).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidEvaluation {
iteration: 0,
defect: EvaluationDefect::JacobianEntry { row: 0, col: 0 }
}
),
"{err:?}"
);
}
#[test]
fn test_system_nonfinite_cost_and_rhs_are_errors() {
struct BadSys {
cost: f64,
rhs: f64,
}
impl CostProblem<1> for BadSys {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(1.0)
}
}
impl SystemProblem<1> for BadSys {
fn evaluate_system(&mut self, _x: &[f64; 1]) -> Result<SystemEvaluation<1>, TestError> {
Ok(SystemEvaluation {
cost: self.cost,
normal: [[1.0]],
rhs: [self.rhs],
})
}
}
let err = solve_system(
&mut BadSys {
cost: f64::NAN,
rhs: 0.0,
},
[0.0],
&config(),
)
.unwrap_err();
assert!(
matches!(
err,
LMError::InvalidSystem {
iteration: 0,
defect: SystemDefect::NonFiniteCost
}
),
"{err:?}"
);
let err = solve_system(
&mut BadSys {
cost: 1.0,
rhs: f64::NAN,
},
[0.0],
&config(),
)
.unwrap_err();
assert!(
matches!(
err,
LMError::InvalidSystem {
iteration: 0,
defect: SystemDefect::NonFiniteRhs { index: 0 }
}
),
"{err:?}"
);
}
#[test]
fn test_invalid_prior_nonfinite_covariance_inv() {
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0]], vec![[1.0]]));
let prior = NLLSPrior {
mean: [0.0],
covariance_inv: [[f64::NAN]],
};
let err = solve(&mut p, [0.0], &config(), Some(&prior)).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidPrior {
defect: PriorDefect::NonFiniteCovarianceInv { row: 0, col: 0 }
}
),
"{err:?}"
);
}
#[test]
fn test_invalid_config_tau_exceeds_mu_max() {
let mut cfg = config();
cfg.tau = 1.0;
cfg.mu_max = 0.5;
let mut p = Tracked::new(|x: &[f64; 1]| (vec![x[0]], vec![[1.0]]));
let err = solve(&mut p, [1.0], &cfg, None).unwrap_err();
assert!(
matches!(
err,
LMError::InvalidConfig {
defect: ConfigDefect::TauExceedsMuMax { .. }
}
),
"{err:?}"
);
}
#[test]
fn test_asymmetric_prior_is_symmetrized_not_rejected() {
let mut p = Tracked::new(|x: &[f64; 2]| {
(vec![x[0] - 10.0, x[1] - 10.0], vec![[1.0, 0.0], [0.0, 1.0]])
});
let prior = NLLSPrior {
mean: [0.0, 0.0],
covariance_inv: [[1.0, 0.1 + 1e-16], [0.1 - 1e-16, 1.0]],
};
let sol = solve(&mut p, [0.0, 0.0], &config(), Some(&prior)).unwrap();
assert!(sol.converged, "{:?}", sol.reason);
let expected = 10.0 / 2.1;
assert!((sol.x[0] - expected).abs() < 1e-6, "x0={}", sol.x[0]);
assert!((sol.x[1] - expected).abs() < 1e-6, "x1={}", sol.x[1]);
}
#[test]
fn test_rollback_on_domain_error_at_accepted_point() {
struct FailsAfterFirst {
full_calls: usize,
}
impl CostProblem<1> for FailsAfterFirst {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok((x[0] - 3.0) * (x[0] - 3.0))
}
}
impl ResidualProblem<1> for FailsAfterFirst {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.full_calls += 1;
if self.full_calls > 1 {
return Err(TestError("propagation died at accepted point"));
}
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let err = solve(
&mut FailsAfterFirst { full_calls: 0 },
[0.0],
&config(),
None,
)
.unwrap_err();
match err {
LMError::PersistentInvalidTrials {
last_source,
best_x,
..
} => {
assert_eq!(
last_source,
Some(TestError("propagation died at accepted point"))
);
assert_eq!(best_x, vec![0.0]);
}
other => panic!("expected PersistentInvalidTrials, got {other:?}"),
}
}
#[test]
fn test_hard_defect_mid_solve_is_fatal() {
struct BreaksContract {
full_calls: usize,
}
impl CostProblem<1> for BreaksContract {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok((x[0] - 3.0) * (x[0] - 3.0))
}
}
impl ResidualProblem<1> for BreaksContract {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.full_calls += 1;
if self.full_calls > 1 {
return Ok(NLLSEvaluation {
residuals: vec![1.0, 2.0],
jacobian: vec![[1.0]],
cost: 5.0,
});
}
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let err = solve(
&mut BreaksContract { full_calls: 0 },
[0.0],
&config(),
None,
)
.unwrap_err();
assert!(
matches!(
err,
LMError::DimensionMismatch {
iteration: 1,
residuals: 2,
jacobian: 1
}
),
"{err:?}"
);
}
#[test]
fn test_pred_overflow_forces_rejection() {
struct HugeClamp {
inner: Tracked<fn(&[f64; 1]) -> Resid1>,
}
impl CostProblem<1> for HugeClamp {
type Error = TestError;
fn evaluate_cost(&mut self, _x: &[f64; 1]) -> Result<f64, TestError> {
Ok(0.0)
}
fn constrain_step(&mut self, _x: &[f64; 1], delta: &mut [f64; 1]) {
delta[0] = 1e200;
}
}
impl ResidualProblem<1> for HugeClamp {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.inner.evaluate(x)
}
}
fn scaled(x: &[f64; 1]) -> Resid1 {
(vec![1e150 * (x[0] - 1.0)], vec![[1e150]])
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
cfg.max_iterations = 3;
let mut p = HugeClamp {
inner: Tracked::new(scaled as fn(&[f64; 1]) -> _),
};
let sol = solve(&mut p, [0.0], &cfg, None).unwrap();
assert_eq!(sol.x[0], 0.0, "overflowed-pred step must not be accepted");
assert!(!sol.converged);
}
#[test]
fn test_committed_state_bit_identical_across_rejections() {
struct Staged {
committed: u64,
pending: u64,
}
impl CostProblem<1> for Staged {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
self.pending = x[0].to_bits();
Ok(10.0) }
fn on_step_accepted(&mut self, _x: &[f64; 1]) {
self.committed = self.pending;
}
}
impl ResidualProblem<1> for Staged {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
self.pending = x[0].to_bits();
Ok(NLLSEvaluation {
residuals: vec![1.0],
jacobian: vec![[1.0]],
cost: 1.0,
})
}
}
let mut cfg = config();
cfg.gtol = 0.0;
cfg.qtol = 0.0;
cfg.xtol = 0.0;
cfg.ftol = 0.0;
let mut p = Staged {
committed: u64::MAX,
pending: u64::MAX,
};
let x0 = 5.0_f64;
let sol = solve(&mut p, [x0], &cfg, None).unwrap();
assert!(!sol.converged);
assert_eq!(
p.committed,
x0.to_bits(),
"committed state must still be the x0 commit, bit-for-bit"
);
}
struct RosenbrockGeo {
hook_calls: std::cell::Cell<usize>,
}
impl CostProblem<2> for RosenbrockGeo {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 2]) -> Result<f64, TestError> {
let r1 = 10.0 * (x[1] - x[0] * x[0]);
let r2 = 1.0 - x[0];
Ok(r1 * r1 + r2 * r2)
}
fn second_directional_derivative(
&mut self,
_x: &[f64; 2],
v: &[f64; 2],
) -> Option<Vec<f64>> {
self.hook_calls.set(self.hook_calls.get() + 1);
Some(vec![-20.0 * v[0] * v[0], 0.0])
}
}
impl ResidualProblem<2> for RosenbrockGeo {
fn evaluate(&mut self, x: &[f64; 2]) -> Result<NLLSEvaluation<2>, TestError> {
let r1 = 10.0 * (x[1] - x[0] * x[0]);
let r2 = 1.0 - x[0];
Ok(NLLSEvaluation {
residuals: vec![r1, r2],
jacobian: vec![[-20.0 * x[0], 10.0], [-1.0, 0.0]],
cost: r1 * r1 + r2 * r2,
})
}
}
#[test]
fn test_geodesic_accelerates_rosenbrock() {
let run = |geodesic: bool| {
let mut cfg = config();
cfg.geodesic_acceleration = geodesic;
cfg.max_iterations = 500;
let mut p = RosenbrockGeo {
hook_calls: std::cell::Cell::new(0),
};
let sol = solve(&mut p, [-1.2, 1.0], &cfg, None).unwrap();
(sol, p.hook_calls.get())
};
let (off, off_calls) = run(false);
let (on, on_calls) = run(true);
assert_eq!(off_calls, 0, "hook must not be called with the flag off");
assert!(on_calls > 0, "hook must be exercised with the flag on");
assert!(off.converged && on.converged);
assert!((on.x[0] - 1.0).abs() < 1e-6 && (on.x[1] - 1.0).abs() < 1e-6);
assert!((off.x[0] - 1.0).abs() < 1e-6 && (off.x[1] - 1.0).abs() < 1e-6);
assert!(on.n_accelerated_trials > 0);
assert!(
on.n_cost_evals < off.n_cost_evals,
"acceleration must reduce cost evaluations: on={} off={}",
on.n_cost_evals,
off.n_cost_evals
);
}
#[test]
fn test_geodesic_avmax_guard_rejects_huge_curvature() {
struct HugeCurvature;
impl CostProblem<1> for HugeCurvature {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok((x[0] - 3.0) * (x[0] - 3.0))
}
fn second_directional_derivative(
&mut self,
_x: &[f64; 1],
_v: &[f64; 1],
) -> Option<Vec<f64>> {
Some(vec![1e12])
}
}
impl ResidualProblem<1> for HugeCurvature {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let mut cfg = config();
cfg.geodesic_acceleration = true;
let sol = solve(&mut HugeCurvature, [0.0], &cfg, None).unwrap();
assert_eq!(sol.n_accelerated_trials, 0);
assert!(sol.n_rejected_trials > 0, "avmax violations must reject");
assert!(!sol.converged || (sol.x[0] - 3.0).abs() < 1e-6, "{sol:?}");
}
#[test]
fn test_geodesic_wrong_length_is_error() {
struct WrongLen;
impl CostProblem<1> for WrongLen {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 1]) -> Result<f64, TestError> {
Ok((x[0] - 3.0) * (x[0] - 3.0))
}
fn second_directional_derivative(
&mut self,
_x: &[f64; 1],
_v: &[f64; 1],
) -> Option<Vec<f64>> {
Some(vec![0.0, 0.0, 0.0])
}
}
impl ResidualProblem<1> for WrongLen {
fn evaluate(&mut self, x: &[f64; 1]) -> Result<NLLSEvaluation<1>, TestError> {
Ok(NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0) * (x[0] - 3.0),
})
}
}
let mut cfg = config();
cfg.geodesic_acceleration = true;
let err = solve(&mut WrongLen, [0.0], &cfg, None).unwrap_err();
assert!(matches!(err, LMError::DimensionMismatch { .. }), "{err:?}");
}
#[test]
fn test_geodesic_inert_on_system_path() {
struct CountingQuad {
inner: QuadSystem,
hook_calls: usize,
}
impl CostProblem<2> for CountingQuad {
type Error = TestError;
fn evaluate_cost(&mut self, x: &[f64; 2]) -> Result<f64, TestError> {
self.inner.evaluate_cost(x)
}
fn second_directional_derivative(
&mut self,
_x: &[f64; 2],
_v: &[f64; 2],
) -> Option<Vec<f64>> {
self.hook_calls += 1;
Some(vec![0.0])
}
}
impl SystemProblem<2> for CountingQuad {
fn evaluate_system(&mut self, x: &[f64; 2]) -> Result<SystemEvaluation<2>, TestError> {
self.inner.evaluate_system(x)
}
}
let mut cfg = config();
cfg.geodesic_acceleration = true;
let mut p = CountingQuad {
inner: QuadSystem {
q: [[2.0, 0.0], [0.0, 8.0]],
a: [1.0, 2.0],
c: 3.0,
},
hook_calls: 0,
};
let sol = solve_system(&mut p, [10.0, -4.0], &cfg).unwrap();
assert!(sol.converged);
assert_eq!(p.hook_calls, 0, "system path must never call the hook");
assert_eq!(sol.n_accelerated_trials, 0);
}
#[test]
fn test_golden_trace_bit_determinism() {
let run = || {
let mut p = Tracked::new(overshoot_rational);
solve(&mut p, [6.5], &config(), None).unwrap()
};
let a = run();
let b = run();
assert_eq!(a.x[0].to_bits(), b.x[0].to_bits());
assert_eq!(a.cost.to_bits(), b.cost.to_bits());
assert_eq!(a.mu_final.to_bits(), b.mu_final.to_bits());
assert_eq!(a.iterations, b.iterations);
assert_eq!(a.n_cost_evals, b.n_cost_evals);
assert_eq!(a.n_rejected_trials, b.n_rejected_trials);
assert_eq!(a.x[0].to_bits(), GOLDEN_X_BITS, "x = {:?}", a.x[0]);
assert_eq!(a.cost.to_bits(), GOLDEN_COST_BITS, "cost = {:?}", a.cost);
assert_eq!(
a.mu_final.to_bits(),
GOLDEN_MU_FINAL_BITS,
"mu = {:?}",
a.mu_final
);
assert_eq!(a.iterations, GOLDEN_ITERATIONS);
assert_eq!(a.n_cost_evals, GOLDEN_COST_EVALS);
assert_eq!(a.n_rejected_trials, GOLDEN_REJECTED);
let mut p = Tracked::new(overshoot_rational);
let _ = solve(&mut p, [6.5], &config(), None).unwrap();
let accepted_bits: Vec<u64> = p.accepted.iter().map(|v| v[0].to_bits()).collect();
let rejected_bits: Vec<u64> = p.rejected.iter().map(|v| v[0].to_bits()).collect();
assert_eq!(
accepted_bits, GOLDEN_ACCEPTED_BITS,
"accepted iterate sequence changed"
);
assert_eq!(
rejected_bits, GOLDEN_REJECTED_BITS,
"rejected trial sequence changed"
);
}
const GOLDEN_MU_FINAL_BITS: u64 = 4565320297239836560;
const GOLDEN_ACCEPTED_BITS: [u64; 10] = [
4619004367821864960, 4618792513637290406,
4618291282969069438,
4617770008120803146,
4617507755895746747,
4617364428888646038,
4617321199543857098,
4617315762137427473,
4617315521566082796,
4617315517979513644, ];
const GOLDEN_REJECTED_BITS: [u64; 5] = [
4594659349414467680,
4594887588787221536,
4596247515049879712,
4602933352259637776,
4614750054244392936,
];
const GOLDEN_X_BITS: u64 = 4617315517979513644;
const GOLDEN_COST_BITS: u64 = 4394527585940473101;
const GOLDEN_ITERATIONS: usize = 10;
const GOLDEN_COST_EVALS: usize = 14;
const GOLDEN_REJECTED: usize = 5;
}