Skip to main content

echidna_optim/
result.rs

1use std::fmt;
2
3/// Result of an optimization run.
4///
5/// Marked `#[non_exhaustive]` so we can add fields without further
6/// breaking-change releases. Construct via the solver entry points
7/// (`lbfgs`, `newton`, `trust_region`, ...) — never with a struct
8/// literal.
9#[non_exhaustive]
10#[derive(Debug, Clone)]
11#[must_use]
12pub struct OptimResult<F> {
13    /// Solution point.
14    pub x: Vec<F>,
15    /// Objective value at the solution.
16    pub value: F,
17    /// Gradient at the solution.
18    pub gradient: Vec<F>,
19    /// Norm of the gradient at the solution.
20    pub gradient_norm: F,
21    /// Number of outer iterations performed.
22    pub iterations: usize,
23    /// Total number of objective function evaluations.
24    pub func_evals: usize,
25    /// Reason for termination.
26    pub termination: TerminationReason,
27    /// Per-solver diagnostic counters surfacing internal events that
28    /// would otherwise be silent (curvature pair filtering, gamma
29    /// clamps, line-search backtracks, Newton fallback steps, trust-
30    /// region radius shrinks, CG inner iterations).
31    ///
32    /// Use this to detect when a solver reports `GradientNorm`
33    /// convergence but actually spent most of its work in fallback or
34    /// filtering paths — a sign that the problem doesn't suit the
35    /// chosen solver.
36    pub diagnostics: SolverDiagnostics,
37}
38
39impl<F> OptimResult<F> {
40    /// Assemble a result. The single construction path for every solver
41    /// return — the struct is `#[non_exhaustive]`, so adding a field means
42    /// touching exactly this signature and its call sites.
43    #[allow(clippy::too_many_arguments)]
44    pub(crate) fn assemble(
45        x: Vec<F>,
46        value: F,
47        gradient: Vec<F>,
48        gradient_norm: F,
49        iterations: usize,
50        func_evals: usize,
51        termination: TerminationReason,
52        diagnostics: SolverDiagnostics,
53    ) -> Self {
54        OptimResult {
55            x,
56            value,
57            gradient,
58            gradient_norm,
59            iterations,
60            func_evals,
61            termination,
62            diagnostics,
63        }
64    }
65}
66
67/// Why the optimizer stopped.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TerminationReason {
70    /// Gradient norm fell below tolerance.
71    GradientNorm,
72    /// Step size fell below tolerance.
73    StepSize,
74    /// Change in objective value fell below tolerance.
75    FunctionChange,
76    /// Reached the maximum number of iterations.
77    MaxIterations,
78    /// Line search could not find a sufficient decrease.
79    LineSearchFailed,
80    /// A numerical error occurred (e.g. singular Hessian, NaN).
81    NumericalError,
82}
83
84impl fmt::Display for TerminationReason {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            TerminationReason::GradientNorm => write!(f, "gradient norm below tolerance"),
88            TerminationReason::StepSize => write!(f, "step size below tolerance"),
89            TerminationReason::FunctionChange => write!(f, "function change below tolerance"),
90            TerminationReason::MaxIterations => write!(f, "maximum iterations reached"),
91            TerminationReason::LineSearchFailed => write!(f, "line search failed"),
92            TerminationReason::NumericalError => write!(f, "numerical error"),
93        }
94    }
95}
96
97/// Per-solver diagnostic counters.
98///
99/// Each variant carries the counters that solver tracks. The enum
100/// shape (rather than a flat struct with optional fields) makes it
101/// impossible to confuse "this solver doesn't track this counter"
102/// with "this counter genuinely observed zero".
103///
104/// Marked `#[non_exhaustive]` so future solver additions don't keep
105/// breaking downstream `match` exhaustiveness.
106///
107/// # Example
108///
109/// ```ignore
110/// use echidna_optim::{lbfgs, LbfgsConfig, SolverDiagnostics, TerminationReason};
111/// let result = lbfgs(&mut obj, &x0, &LbfgsConfig::default());
112/// if let SolverDiagnostics::Lbfgs(d) = &result.diagnostics {
113///     if result.termination == TerminationReason::GradientNorm
114///        && d.pairs_curvature_rejected > d.pairs_accepted
115///     {
116///         eprintln!("L-BFGS converged but ran mostly as steepest descent — \
117///                    consider a different solver or rescale the problem");
118///     }
119/// }
120/// ```
121#[non_exhaustive]
122#[derive(Debug, Clone)]
123pub enum SolverDiagnostics {
124    /// L-BFGS-specific counters.
125    Lbfgs(LbfgsDiagnostics),
126    /// Newton-specific counters.
127    Newton(NewtonDiagnostics),
128    /// Trust-region-specific counters.
129    TrustRegion(TrustRegionDiagnostics),
130}
131
132impl SolverDiagnostics {
133    /// Returns the L-BFGS counters if this result came from `lbfgs`.
134    #[must_use]
135    pub fn as_lbfgs(&self) -> Option<&LbfgsDiagnostics> {
136        match self {
137            SolverDiagnostics::Lbfgs(d) => Some(d),
138            _ => None,
139        }
140    }
141
142    /// Returns the Newton counters if this result came from `newton`.
143    #[must_use]
144    pub fn as_newton(&self) -> Option<&NewtonDiagnostics> {
145        match self {
146            SolverDiagnostics::Newton(d) => Some(d),
147            _ => None,
148        }
149    }
150
151    /// Returns the trust-region counters if this result came from `trust_region`.
152    #[must_use]
153    pub fn as_trust_region(&self) -> Option<&TrustRegionDiagnostics> {
154        match self {
155            SolverDiagnostics::TrustRegion(d) => Some(d),
156            _ => None,
157        }
158    }
159}
160
161/// Counters surfaced by the L-BFGS solver.
162#[derive(Debug, Clone, Default)]
163pub struct LbfgsDiagnostics {
164    /// Number of (s, y) curvature pairs that passed the Cauchy-Schwarz
165    /// filter `sy > sqrt(F::epsilon()) · sqrt(ss · yy)` and entered the
166    /// history buffer.
167    pub pairs_accepted: usize,
168    /// Number of curvature pairs rejected by the filter
169    /// `sy > sqrt(F::epsilon()) · sqrt(ss · yy)` (negative or near-zero
170    /// curvature, i.e. cosine angle near 0 between `s` and `y`).
171    pub pairs_curvature_rejected: usize,
172    /// Number of evict-then-push events: a new accepted pair was added
173    /// while the history buffer was already at `config.memory`, so the
174    /// oldest pair was dropped. With the FIFO eviction policy used here,
175    /// the invariant `pairs_evicted_by_memory == max(0, pairs_accepted
176    /// - config.memory)` holds exactly at termination.
177    pub pairs_evicted_by_memory: usize,
178    /// Number of iterations where the initial L-BFGS gamma was clamped
179    /// to the open range `(1e-3, 1e3)` (i.e. `raw_gamma` was strictly
180    /// outside) or substituted with `1.0` because `sy/yy` was non-finite.
181    /// A `raw_gamma` exactly equal to a clamp boundary is not counted.
182    pub gamma_clamp_hits: usize,
183    /// Total Armijo line-search trial points beyond the first per outer
184    /// iteration, summed across all iterations. A high value relative
185    /// to `iterations` signals the search direction is poorly scaled.
186    pub line_search_backtracks: usize,
187}
188
189/// Counters surfaced by the Newton solver.
190#[derive(Debug, Clone, Default)]
191pub struct NewtonDiagnostics {
192    /// Number of iterations where the LU solve failed or returned a
193    /// non-descent direction, forcing the steepest-descent fallback.
194    pub fallback_steps: usize,
195    /// Total Armijo line-search trial points beyond the first.
196    pub line_search_backtracks: usize,
197}
198
199/// Counters surfaced by the trust-region solver.
200#[derive(Debug, Clone, Default)]
201pub struct TrustRegionDiagnostics {
202    /// Sum of inner Steihaug-CG iterations across all outer iterations.
203    pub cg_inner_iters: usize,
204    /// Trust-region radius shrinks because the predicted reduction was
205    /// non-positive (the quadratic model itself is unreliable).
206    pub radius_shrinks_bad_model: usize,
207    /// Trust-region radius shrinks because `actual / predicted < 1/4`
208    /// (the model over-predicted reduction).
209    pub radius_shrinks_low_rho: usize,
210}