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}