Skip to main content

optirs_core/plugin/validation/
convergence.rs

1//! Convergence validation for optimizer plugins.
2//!
3//! Split out of `validation.rs` to keep every file under the project's
4//! 2000-line limit. Holds [`ConvergenceTestSuite`], which runs a plugin against
5//! a convex [`TestProblem`] and asserts the loss actually decreases -- the one
6//! property any optimizer claiming to optimize must satisfy.
7
8use super::{SuiteResult, TestResult, TestSummary, ValidationConfig, ValidationTestSuite};
9use crate::plugin::core::*;
10use scirs2_core::ndarray::Array1;
11use scirs2_core::numeric::Float;
12use std::collections::HashMap;
13use std::time::Instant;
14
15/// Type alias for objective function
16pub(super) type ObjectiveFn<A> = Box<dyn Fn(&Array1<A>) -> A + Send + Sync>;
17
18/// Type alias for gradient function
19pub(super) type GradientFn<A> = Box<dyn Fn(&Array1<A>) -> Array1<A> + Send + Sync>;
20
21/// Convergence test suite
22#[derive(Debug)]
23pub struct ConvergenceTestSuite<A: Float + std::fmt::Debug + Send + Sync> {
24    config: ValidationConfig,
25    test_problems: Vec<TestProblem<A>>,
26}
27
28impl<A: Float + std::fmt::Debug + Send + Sync + 'static> ConvergenceTestSuite<A> {
29    /// Create a new convergence test suite with the standard convex problems.
30    ///
31    /// `test_problems` was an empty vector nothing populated or read; the
32    /// quadratic problem the suite actually runs is now one of its entries, so
33    /// the field describes the suite instead of decorating it.
34    pub fn new(config: ValidationConfig) -> Self {
35        let dimension = config
36            .test_data_sizes
37            .iter()
38            .copied()
39            .find(|size| *size > 0)
40            .unwrap_or(4)
41            .min(4096);
42        Self {
43            config,
44            test_problems: vec![TestProblem::sum_of_squares(dimension)],
45        }
46    }
47
48    /// The convergence problems this suite runs.
49    pub fn test_problems(&self) -> &[TestProblem<A>] {
50        &self.test_problems
51    }
52}
53
54impl<A: Float + std::fmt::Debug + Send + Sync> ConvergenceTestSuite<A> {
55    /// Run the plugin on the convex quadratic `f(x) = sum(x_i^2)`
56    /// (gradient `2x`) and assert the loss actually decreases -- the one
57    /// property any optimizer claiming to optimize must satisfy.
58    fn test_quadratic_convergence(&self, plugin: &mut dyn OptimizerPlugin<A>) -> TestResult {
59        let start_time = Instant::now();
60        if !self.config.check_convergence {
61            return TestResult {
62                passed: true,
63                message: "convergence testing disabled by ValidationConfig::check_convergence"
64                    .to_string(),
65                execution_time: start_time.elapsed(),
66                data: HashMap::new(),
67            };
68        }
69        // Drive the run from the configured problem rather than literals, so a
70        // caller that widened `test_data_sizes` actually gets a wider problem.
71        let problem = match self.test_problems.first() {
72            Some(problem) => problem,
73            None => {
74                return TestResult {
75                    passed: false,
76                    message: "no convergence problem is configured".to_string(),
77                    execution_time: start_time.elapsed(),
78                    data: HashMap::new(),
79                }
80            }
81        };
82        let dim = problem.initial_params.len();
83        let iterations = problem.max_iterations;
84
85        if let Err(e) = plugin.initialize(&[dim]) {
86            return TestResult {
87                passed: false,
88                message: format!("initialize failed before convergence run: {e}"),
89                execution_time: start_time.elapsed(),
90                data: HashMap::new(),
91            };
92        }
93
94        let mut params: Array1<A> = problem.initial_params.clone();
95        let loss = |p: &Array1<A>| -> A { (problem.objective_fn)(p) };
96        let initial_loss = loss(&params);
97
98        for step in 0..iterations {
99            let gradients = (problem.gradient_fn)(&params);
100            match plugin.step(&params, &gradients) {
101                Ok(next) => params = next,
102                Err(e) => {
103                    return TestResult {
104                        passed: false,
105                        message: format!(
106                            "step failed at iteration {step} during convergence run: {e}"
107                        ),
108                        execution_time: start_time.elapsed(),
109                        data: HashMap::new(),
110                    };
111                }
112            }
113            let current_loss = loss(&params);
114            if !current_loss.is_finite() {
115                return TestResult {
116                    passed: false,
117                    message: format!("loss diverged to a non-finite value by iteration {step}"),
118                    execution_time: start_time.elapsed(),
119                    data: HashMap::new(),
120                };
121            }
122        }
123
124        let final_loss = loss(&params);
125        let passed = final_loss < initial_loss;
126
127        TestResult {
128            passed,
129            message: format!(
130                "{} over {iterations} steps: initial={initial_loss:?} final={final_loss:?}",
131                problem.name
132            ),
133            execution_time: start_time.elapsed(),
134            data: HashMap::new(),
135        }
136    }
137}
138
139impl<A: Float + std::fmt::Debug + Send + Sync> ValidationTestSuite<A> for ConvergenceTestSuite<A> {
140    fn run_tests(&self, plugin: &mut dyn OptimizerPlugin<A>) -> SuiteResult {
141        let start_time = Instant::now();
142        let result = self.test_quadratic_convergence(plugin);
143        let passed = result.passed;
144
145        SuiteResult {
146            suite_name: "Convergence".to_string(),
147            test_results: vec![result],
148            suite_passed: passed,
149            execution_time: start_time.elapsed(),
150            summary: TestSummary {
151                total_tests: 1,
152                passed_tests: passed as usize,
153                failed_tests: (!passed) as usize,
154                skipped_tests: 0,
155                success_rate: if passed { 1.0 } else { 0.0 },
156            },
157            verified: true,
158        }
159    }
160
161    fn name(&self) -> &str {
162        "Convergence Tests"
163    }
164
165    fn description(&self) -> &str {
166        "Tests for optimization convergence"
167    }
168
169    fn test_count(&self) -> usize {
170        1
171    }
172}
173
174/// Test problem for convergence testing
175pub struct TestProblem<A: Float + std::fmt::Debug> {
176    /// Problem name
177    pub name: String,
178    /// Initial parameters
179    pub initial_params: Array1<A>,
180    /// Objective function
181    pub objective_fn: ObjectiveFn<A>,
182    /// Gradient function
183    pub gradient_fn: GradientFn<A>,
184    /// Known optimal value
185    pub optimal_value: Option<A>,
186    /// Maximum iterations
187    pub max_iterations: usize,
188    /// Convergence tolerance
189    pub convergence_tolerance: A,
190}
191
192impl<A: Float + std::fmt::Debug + Send + Sync + 'static> TestProblem<A> {
193    /// The convex quadratic `f(x) = sum(x_i^2)`, gradient `2x`, optimum `0`.
194    ///
195    /// The one problem every optimizer claiming to optimize must make progress
196    /// on, and the problem [`ConvergenceTestSuite`] actually runs.
197    pub fn sum_of_squares(dimension: usize) -> Self {
198        let dimension = dimension.max(1);
199        Self {
200            name: format!("sum_of_squares_{dimension}d"),
201            initial_params: Array1::from_iter(
202                (0..dimension).map(|i| A::from(2.0 + i as f64).unwrap_or_else(A::one)),
203            ),
204            objective_fn: Box::new(|params: &Array1<A>| {
205                params.iter().fold(A::zero(), |acc, &x| acc + x * x)
206            }),
207            gradient_fn: Box::new(|params: &Array1<A>| params.mapv(|x| x + x)),
208            optimal_value: Some(A::zero()),
209            max_iterations: 200,
210            convergence_tolerance: A::from(1e-6).unwrap_or_else(A::zero),
211        }
212    }
213}
214
215impl<A: Float + std::fmt::Debug + Send + Sync> std::fmt::Debug for TestProblem<A> {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("TestProblem")
218            .field("name", &self.name)
219            .field("initial_params", &self.initial_params)
220            .field("objective_fn", &"<function>")
221            .field("gradient_fn", &"<function>")
222            .field("optimal_value", &self.optimal_value)
223            .field("max_iterations", &self.max_iterations)
224            .field("convergence_tolerance", &self.convergence_tolerance)
225            .finish()
226    }
227}