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