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}