use basin::{
Backtracking, CostFunction, Executor, FirstOrderState, Gradient,
GradientDescent, GradientState, NelderMead, PointState, Problem,
SimplexProgress, Solver, State, TerminationCode,
};
use std::time::Duration;
#[test]
fn termination_reasons_preserve_numeric_casts_and_values() {
let reasons = [
TerminationCode::MaxIter,
TerminationCode::MaxCostEvals,
TerminationCode::MaxGradientEvals,
TerminationCode::GradientTolerance,
TerminationCode::RelativeGradientTolerance,
TerminationCode::ProjectedGradientTolerance,
TerminationCode::ParamTolerance,
TerminationCode::RelativeParamTolerance,
TerminationCode::CostTolerance,
TerminationCode::RelativeCostTolerance,
TerminationCode::TargetCost,
TerminationCode::NoImprovement,
TerminationCode::NoAcceptedMove,
TerminationCode::SimplexTolerance,
TerminationCode::CmaEsTolerance,
TerminationCode::RhoTolerance,
TerminationCode::MeshTolerance,
TerminationCode::MaxTime,
TerminationCode::Cancelled,
TerminationCode::UserRequested,
TerminationCode::SolverConverged,
TerminationCode::SolverFailed,
TerminationCode::NumericalNoProgress,
];
for (reason, value) in reasons.into_iter().zip(0u8..) {
assert_eq!(reason as u8, value);
}
}
struct Quadratic;
impl basin::ProblemConstraints for Quadratic {
type Constraints = basin::NoConstraints;
fn constraint_set(
&self,
) -> <Self::Constraints as basin::ConstraintDescription>::Borrowed<'_> {
basin::Constraints::new()
}
}
impl CostFunction for Quadratic {
type Param = Vec<f64>;
type Output = f64;
type Error = std::convert::Infallible;
fn cost(&self, x: &Vec<f64>) -> Result<f64, std::convert::Infallible> {
Ok(0.5 * x.iter().map(|v| v * v).sum::<f64>())
}
}
impl Gradient for Quadratic {
type Gradient = Vec<f64>;
fn gradient(
&self,
x: &Vec<f64>,
) -> Result<Vec<f64>, std::convert::Infallible> {
Ok(x.clone())
}
}
#[test]
fn gradient_tolerance_fires_at_iter_zero_when_starting_at_optimum() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1).with_absolute_gradient_tolerance(1e-8),
FirstOrderState::new(vec![0.0, 0.0]),
)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::GradientTolerance);
assert_eq!(result.iter(), 0, "should not have done any iterations");
}
#[test]
fn gradient_tolerance_fires_after_convergence() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5).with_absolute_gradient_tolerance(1e-6),
FirstOrderState::new(vec![1.0, -1.0, 0.5]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::GradientTolerance);
assert!(result.iter() > 0 && result.iter() < 1_000);
let g = result
.state
.gradient()
.expect("gradient should be populated");
let g_norm = g.iter().map(|v| v * v).sum::<f64>().sqrt();
assert!(g_norm <= 1e-6);
}
#[test]
fn relative_gradient_tolerance_fires_after_convergence() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5).with_relative_gradient_tolerance(1e-3),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(
result.report.code(),
TerminationCode::RelativeGradientTolerance
);
assert!(
result.iter() > 0 && result.iter() < 20,
"expected convergence near iter 10, got {}",
result.iter()
);
}
#[test]
fn relative_gradient_tolerance_fires_at_iter_zero_when_starting_at_optimum() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1).with_relative_gradient_tolerance(1e-6),
FirstOrderState::new(vec![0.0, 0.0]),
)
.run()
.unwrap();
assert_eq!(
result.report.code(),
TerminationCode::RelativeGradientTolerance
);
assert_eq!(result.iter(), 0);
}
#[test]
fn relative_gradient_tolerance_is_scale_invariant() {
let run_from = |x0: f64| {
Executor::new(
Quadratic,
GradientDescent::new(0.5).with_relative_gradient_tolerance(1e-3),
FirstOrderState::new(vec![x0, x0]),
)
.max_iter(1_000)
.run()
.unwrap()
};
let small = run_from(1.0);
let large = run_from(1.0e6);
assert_eq!(
small.report.code(),
TerminationCode::RelativeGradientTolerance
);
assert_eq!(
large.report.code(),
TerminationCode::RelativeGradientTolerance
);
assert_eq!(
small.iter(),
large.iter(),
"relative gradient tolerance should be scale-invariant"
);
}
#[test]
fn max_iter_field_default_is_one_thousand() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001), FirstOrderState::new(vec![10.0, 10.0]),
)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxIter);
assert_eq!(result.iter(), 1_000);
}
#[test]
fn explicit_max_iter_replaces_default() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001),
FirstOrderState::new(vec![10.0, 10.0]),
)
.max_iter(5)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxIter);
assert_eq!(result.iter(), 5);
}
#[test]
fn param_tolerance_fires_when_steps_become_small() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5).with_absolute_step_tolerance(1e-8),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::ParamTolerance);
}
#[test]
fn cost_tolerance_fires_when_cost_stagnates() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5).with_absolute_cost_change_tolerance(1e-12),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::CostTolerance);
}
#[test]
fn relative_param_tolerance_fires_when_relative_step_small() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001).with_relative_step_tolerance(1e-2),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(
result.report.code(),
TerminationCode::RelativeParamTolerance
);
assert!(result.iter() < 5, "fired late at iter {}", result.iter());
}
#[test]
fn relative_cost_tolerance_fires_when_relative_reduction_small() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001).with_relative_cost_change_tolerance(1e-2),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::RelativeCostTolerance);
assert!(result.iter() < 5, "fired late at iter {}", result.iter());
}
#[test]
fn target_objective_fires_at_iter_zero_when_start_is_below_target() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1),
FirstOrderState::new(vec![0.5, 0.5]),
)
.target_objective(1.0)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::TargetCost);
assert_eq!(result.iter(), 0);
}
#[test]
fn target_objective_fires_when_cost_drops_to_target() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.target_objective(1e-3)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::TargetCost);
assert!(result.iter() > 0 && result.iter() < 1_000);
assert!(result.state.cost() <= 1e-3);
}
#[test]
fn target_objective_does_not_fire_when_target_unreachable() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(10)
.target_objective(-1.0)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxIter);
assert_eq!(result.iter(), 10);
}
#[test]
fn no_objective_improvement_fires_after_patience_stalled_iters() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(100)
.no_objective_improvement(3, 10.0)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::NoImprovement);
assert_eq!(result.iter(), 3);
}
#[test]
fn no_objective_improvement_does_not_fire_under_monotone_decrease() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(20)
.no_objective_improvement(5, 0.0)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxIter);
assert_eq!(result.iter(), 20);
}
#[test]
fn no_objective_improvement_resets_counter_on_real_improvement() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.5),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(100)
.no_objective_improvement(3, 0.1)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::NoImprovement);
assert_eq!(result.iter(), 5);
}
#[test]
fn relative_cost_tolerance_is_scale_invariant() {
let run_from = |x0: f64| {
Executor::new(
Quadratic,
GradientDescent::new(0.001)
.with_relative_cost_change_tolerance(1e-2),
FirstOrderState::new(vec![x0, x0]),
)
.max_iter(1_000)
.run()
.unwrap()
};
let small = run_from(1.0);
let large = run_from(1.0e6);
assert_eq!(small.report.code(), TerminationCode::RelativeCostTolerance);
assert_eq!(large.report.code(), TerminationCode::RelativeCostTolerance);
assert_eq!(
small.iter(),
large.iter(),
"relative cost tolerance should be scale-invariant"
);
}
#[test]
fn solver_step_tolerance_precedes_iteration_budget() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1).with_absolute_step_tolerance(100.0),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(1_000)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::ParamTolerance);
assert!(result.iter() < 5);
}
#[test]
fn max_time_eventually_fires() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001),
FirstOrderState::new(vec![1e6, 1e6, 1e6]),
)
.max_iter(u64::MAX)
.max_time(Duration::from_millis(50))
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxTime);
}
struct AlwaysConverged;
impl Solver<Quadratic, PointState<Vec<f64>>> for AlwaysConverged {
type Error = std::convert::Infallible;
fn next_iter(
&mut self,
_problem: &mut Problem<Quadratic>,
state: PointState<Vec<f64>>,
) -> Result<basin::SolverStep<PointState<Vec<f64>>>, Self::Error> {
Ok(basin::SolverStep::from((state, None)))
}
fn terminate(
&self,
_state: &PointState<Vec<f64>>,
) -> Option<basin::Termination<<PointState<Vec<f64>> as basin::State>::Float>>
{
Some(basin::Termination::custom(
"external.SolverConverged",
"External solver stopping predicate: SolverConverged.",
vec![],
))
}
}
#[test]
fn solver_terminate_hook_is_honored() {
let result = Executor::new(
Quadratic,
AlwaysConverged,
PointState::new(vec![1.0, 2.0]),
)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::SolverConverged);
assert_eq!(result.iter(), 0);
}
struct FailsOnSecondCall {
calls: u64,
}
impl Solver<Quadratic, PointState<Vec<f64>>> for FailsOnSecondCall {
type Error = std::convert::Infallible;
fn next_iter(
&mut self,
_problem: &mut Problem<Quadratic>,
state: PointState<Vec<f64>>,
) -> Result<basin::SolverStep<PointState<Vec<f64>>>, Self::Error> {
Ok({
self.calls += 1;
if self.calls >= 2 {
basin::SolverStep::stopped(
state,
basin::Termination::numerical_failure(
"External solver reported a numerical failure.",
),
)
} else {
basin::SolverStep::completed(state)
}
})
}
}
#[test]
fn solver_can_signal_termination_mid_iter() {
let result = Executor::new(
Quadratic,
FailsOnSecondCall { calls: 0 },
PointState::new(vec![1.0, 2.0]),
)
.max_iter(100)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::SolverFailed);
assert_eq!(result.iter(), 1);
}
#[test]
fn cost_evals_matches_iter_for_constant_step_gradient_descent() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001),
FirstOrderState::new(vec![10.0, 10.0]),
)
.max_iter(20)
.run()
.unwrap();
assert_eq!(result.iter(), 20);
assert_eq!(result.state.cost_evals(), 21);
}
#[test]
fn cost_evals_exceeds_iter_with_backtracking() {
let result = Executor::new(
Quadratic,
GradientDescent::with_line_search(
Backtracking::new().alpha_init(8.0).rho(0.5),
),
FirstOrderState::new(vec![1.0, 1.0]),
)
.max_iter(10)
.run()
.unwrap();
assert_eq!(result.iter(), 10);
assert!(
result.state.cost_evals() > result.iter() + 1,
"expected line search to inflate cost_evals beyond iter+1: cost_evals={}, iter={}",
result.state.cost_evals(),
result.iter()
);
}
#[test]
fn cost_evals_exceeds_iter_for_nelder_mead_shrinks() {
let result = Executor::new(
Quadratic,
NelderMead::new(),
SimplexProgress::new(vec![2.0, -3.0]),
)
.max_iter(50)
.run()
.unwrap();
assert!(result.state.cost_evals() >= result.iter() + 3);
}
#[test]
fn max_gradient_evals_fires_before_max_iter() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.001),
FirstOrderState::new(vec![10.0, 10.0]),
)
.max_iter(10_000)
.max_gradient_evals(5)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxGradientEvals);
assert!(result.state.gradient_evals() >= 5);
}
#[test]
fn max_cost_evals_fires_before_max_iter() {
let result = Executor::new(
Quadratic,
NelderMead::new(),
SimplexProgress::new(vec![5.0, -2.0, 4.0]),
)
.max_iter(10_000)
.max_cost_evals(25)
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::MaxCostEvals);
assert!(
result.state.cost_evals() >= 25,
"cost_evals should have reached the budget: {}",
result.state.cost_evals()
);
}
#[test]
fn custom_stop_hook() {
let result = Executor::new(
Quadratic,
GradientDescent::new(0.1),
FirstOrderState::new(vec![5.0, 5.0]),
)
.max_iter(1_000)
.stop_when(|state| {
(state.iter() == 7)
.then(|| basin::ApplicationStop::new("user_requested"))
})
.run()
.unwrap();
assert_eq!(result.report.code(), TerminationCode::UserRequested);
assert_eq!(result.iter(), 7);
}