Skip to main content

pounce_nlp/
solve_statistics.rs

1//! Per-solve counters and timers.
2//!
3//! Mirrors `Interfaces/IpSolveStatistics.{hpp,cpp}`. Values are
4//! populated by `IpoptApplication` after a successful solve. This is
5//! a Phase-3 skeleton — the cumulative timer bookkeeping is wired up
6//! in Phase 7 once `IpoptAlg` is producing iterations.
7
8use pounce_common::types::{Index, Number};
9
10/// One row of per-iteration data — same numbers that
11/// `IpoptAlgorithm` prints to stdout each iteration (the "iter
12/// objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls"
13/// line). Captured into [`SolveStatistics::iterations`] when a
14/// JSON / programmatic consumer needs the trajectory rather than
15/// just the final state.
16///
17/// Field semantics mirror upstream `IpOrigIterationOutput.cpp:152`
18/// (`Snprintf` block) so a row in JSON round-trips back into the
19/// same console table verbatim.
20#[derive(Debug, Default, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct IterRecord {
23    /// Iteration index, starting at 0.
24    pub iter: Index,
25    /// Unscaled objective `f(x_k)` at the start of iter `k`.
26    pub objective: Number,
27    /// Primal infeasibility (max-norm of constraint violation).
28    pub inf_pr: Number,
29    /// Dual infeasibility (max-norm of grad-Lagrangian).
30    pub inf_du: Number,
31    /// Barrier parameter μ.
32    pub mu: Number,
33    /// `||d_xs||_∞` of the search step. `0.0` on iter 0 (no step yet).
34    pub d_norm: Number,
35    /// Hessian regularization `δ_w` applied this iter; `0.0` when
36    /// no regularization was needed (printed as `-` in the console).
37    pub regularization: Number,
38    /// Dual step length.
39    pub alpha_dual: Number,
40    /// Primal step length.
41    pub alpha_primal: Number,
42    /// Single-character tag for the alpha-primal column (`f`, `h`,
43    /// `r` for restoration etc.) — matches upstream's per-iter tag.
44    pub alpha_primal_char: char,
45    /// Number of backtracking line-search trials this iter.
46    pub ls_trials: Index,
47}
48
49#[derive(Debug, Clone)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct SolveStatistics {
52    pub iteration_count: Index,
53    pub total_cpu_time_secs: Number,
54    pub total_sys_time_secs: Number,
55    pub total_wallclock_time_secs: Number,
56    pub num_obj_evals: Index,
57    pub num_constr_evals: Index,
58    pub num_obj_grad_evals: Index,
59    pub num_constr_jac_evals: Index,
60    pub num_hess_evals: Index,
61    pub final_objective: Number,
62    pub final_scaled_objective: Number,
63    pub final_dual_inf: Number,
64    pub final_constr_viol: Number,
65    pub final_compl: Number,
66    pub final_kkt_error: Number,
67    // Unscaled (user-original-space) counterparts of the four residuals
68    // above. The `final_*` fields are max-norms in the internally-scaled
69    // NLP space (objective × df, constraints × dc); these divide the
70    // nlp_scaling back out so a consumer can verify a returned KKT
71    // certificate in its own units. Equal to the scaled fields when no
72    // nlp_scaling is active. `final_unscaled_kkt_error` is the plain
73    // max-norm of the three (no s_d/s_c optimality scaling). (pounce#173)
74    /// Primal violation measured against the model **as declared**, before
75    /// the `bound_relax_factor` widening the convex arm applies
76    /// (`qp_extract::BoundRelax`, gh #744/#745).
77    ///
78    /// `final_constr_viol` measures the model the solver was HANDED, whose
79    /// inequality rows and variable box are widened by
80    /// `min(factor, cap)·|b|`. That is the right model for the convergence
81    /// test, and it is what every acceptance gate reads — but it is not how
82    /// far the returned point sits outside the model the caller wrote. On
83    /// netlib `afiro` the point is `4.99e-06` outside a declared row
84    /// `b = 500` (exactly `1e-8·500`) while `final_constr_viol` reads
85    /// `8.68e-13`; on `25fv47` it is `1.97e-05` against `2.19e-11`.
86    ///
87    /// Reported so a caller can tell the two apart rather than reading the
88    /// widened number as its own model's feasibility. `NaN` when the solve
89    /// applied no widening (the two coincide) or on paths that do not
90    /// compute it.
91    pub final_declared_constr_viol: Number,
92    /// How far the returned point sits outside the **declared** variable box
93    /// — the box the caller wrote, before the `bound_relax_factor` widening.
94    /// This is Ipopt's `Variable bound violation`, and it is the box half of
95    /// [`Self::final_declared_constr_viol`] reported on its own, because once
96    /// the two are maxed together a box violation cannot be told from a row
97    /// violation.
98    ///
99    /// Variable bounds carry no scaling — POUNCE scales the objective and the
100    /// constraint rows only — so there is no scaled/unscaled pair here; the
101    /// one number is right in both columns.
102    ///
103    /// `NaN` on a path that does not compute it.
104    pub final_declared_box_viol: Number,
105    pub final_unscaled_dual_inf: Number,
106    pub final_unscaled_constr_viol: Number,
107    pub final_unscaled_compl: Number,
108    pub final_unscaled_kkt_error: Number,
109    /// `final_kkt_error` with each constraint row's residual counted only
110    /// where it rises above what that row can represent in floating point —
111    /// the aggregate the **strict** convergence gate actually tests (gh #528).
112    /// Equal to `final_kkt_error` on every problem whose data is `O(1)`, and
113    /// smaller only where a row is at its own resolution limit. Reported so a
114    /// summary that ends `EXIT: Optimal Solution Found` beside an error above
115    /// `tol` accounts for the gap rather than merely presenting it.
116    pub final_kkt_error_above_noise: Number,
117    /// Final barrier parameter μ at termination (the IPM's `curr_mu`
118    /// after the last iterate). Lets a caller thread the converged
119    /// barrier into a warm-started re-solve's `mu_init` /
120    /// `warm_start_target_mu` for predictor–corrector path following
121    /// (pounce#86). `0.0` on the barrier-free SQP path, where μ has
122    /// no meaning.
123    pub final_mu: Number,
124
125    // ---- Restoration-phase audit counters (pounce#12). ----
126    //
127    // Populated by `IpoptApplication::optimize_constrained` after a
128    // solve completes. All three are 0 when restoration never fires.
129    //
130    /// Number of times `IpoptAlgorithm::invoke_restoration` was
131    /// entered during this solve.
132    /// Finite-difference Hessian census, when
133    /// `hessian_approximation=finite-difference` actually built a pattern.
134    /// All zero / `-1` on every other Hessian mode, which is how a caller
135    /// tells "the mode did not run" from "it ran with an empty pattern".
136    ///
137    /// `fd_hessian_pattern_used` is the source the run **ended up with**,
138    /// not the one requested: `0` declared, `1` jacobian, `-1` not run.
139    /// `declared` silently falls back to `jacobian` when the TNLP declares
140    /// no Hessian structure, and that fallback is the difference between
141    /// 17 probe groups and 341 on `benchmarks/large_scale` `laptime`, so
142    /// reporting the request would hide the number a reader is here for.
143    pub fd_hessian_pattern_used: Index,
144    /// Hessian nonzeros in the pattern that was coloured (lower triangle).
145    pub fd_hessian_nnz: Index,
146    /// Columns the colouring ran over, i.e. the problem's variable count.
147    /// Present so the report is self-contained: `groups / n` is the
148    /// compression, the fraction of a dense finite-difference scheme's
149    /// probes this pattern costs, and without `n` a reader cannot form it.
150    pub fd_hessian_n: Index,
151    /// Probe groups per Hessian — the count of extra gradient/Jacobian
152    /// evaluations each rebuild costs.
153    pub fd_hessian_groups: Index,
154    /// Widest row of the pattern; the quantity that decides whether the
155    /// colouring can stay narrow under mesh refinement.
156    pub fd_hessian_rho_max: Index,
157    /// Whether a requested star colouring failed validation and CPR was
158    /// substituted.
159    pub fd_hessian_coloring_fell_back: bool,
160    /// Whether the objective clique fell back to a conservative structural
161    /// set because the model stated no objective linearity. This is the
162    /// field that explains a surprising `fd_hessian_groups`: the clique is
163    /// then `N`, or all `n`, and the probe count reflects that rather than
164    /// the objective's true support.
165    pub fd_hessian_objective_clique_widened: bool,
166    pub restoration_calls: Index,
167    /// Cumulative inner-IPM iterations across every restoration call —
168    /// the number of `r`-suffix rows a `print_level=5` log would show.
169    ///
170    /// Each call contributes its sub-solve's own *length*: the inner
171    /// counter is seeded from the outer's at entry (upstream
172    /// `IpRestoMinC_1Nrm.cpp:181`), so the length is the terminating value
173    /// minus the outer count at entry. Before gh #819 this summed the
174    /// terminating values themselves — absolute positions in a shared
175    /// numbering, not lengths — and recorded `0` for any call that failed,
176    /// which is the case a reader is looking at this field to understand.
177    pub restoration_inner_iters: Index,
178    /// Number of *outer* iterations consumed by restoration: one per call,
179    /// so this always equals `restoration_calls`.
180    ///
181    /// It is not the count of `r`-suffix rows — that is
182    /// `restoration_inner_iters`, and reading this field as those rows is
183    /// what the doc comment here said until gh #819. Restoration in POUNCE
184    /// is a nested solve entered from a single outer iteration, not a mode
185    /// the outer loop runs in, so there is no third number here to report.
186    pub restoration_outer_iters: Index,
187    /// Cumulative wall-clock seconds spent inside `perform_restoration`
188    /// across all restoration calls. Useful for "what fraction of the
189    /// solve was restoration?" without running with high print_level.
190    pub restoration_wall_secs: Number,
191
192    /// Successful linear-solver quality escalations over the whole solve
193    /// — the main loop's and every restoration sub-solve's — i.e. the
194    /// count of `q` flags in the info-string column (gh#857).
195    ///
196    /// An escalation is not an error and not, on its own, a problem: it
197    /// is how the IPM answers a factorization that will not deliver.
198    /// But with the FERAL backend it *reroutes the rest of the solve*,
199    /// because that backend's ladder changes which pivots are taken and
200    /// never steps back down, so a run that ends badly having escalated
201    /// is a different animal from one that ends badly without. Before
202    /// this counter the two were indistinguishable in a report, which is
203    /// why gh#857's regression had to be found by instrumenting a build.
204    ///
205    /// `0` on the SQP and convex paths, which never escalate, and on any
206    /// run whose backend declines to (`increase_quality` returning
207    /// `false` is not counted — this counts escalations that *happened*,
208    /// not escalations that were asked for).
209    ///
210    /// **On a laddered run this is the promoted solve's count, not the
211    /// base solve's** — the same rule `iteration_count` follows, and the
212    /// same trap. It is a sharp edge here because `feral_increase_quality_retry`
213    /// promotes a re-solve that by construction escalated zero times, so
214    /// a run whose base solve escalated twenty-five times reports `0`
215    /// once the recovery lands. That is not a lost number: the ladder
216    /// block records the base verdict alongside it, and
217    /// `feral_increase_quality_retry=no` reproduces the base solve
218    /// outright. The rung's own gate reads the base statistics inside the
219    /// driver, before any promotion, so the gate is unaffected.
220    pub quality_escalations: Index,
221
222    /// gh#884. The solve observed the biactive dual-divergence signature:
223    /// at one and the same iterate, a converged primal
224    /// (`inf_pr <= dual_divergence_retry_primal_tol`), a scale-relative
225    /// step at or below `dual_divergence_retry_step_tol`, and an
226    /// *unscaled* dual infeasibility at or above
227    /// `dual_divergence_retry_du_floor`.
228    ///
229    /// Reported whether or not a retry ran or promoted, so a caller can
230    /// tell "the multipliers ran away on a settled iterate" from an exit
231    /// that merely ran out of iterations.
232    ///
233    /// Unlike `quality_escalations` and `iteration_count`, which on a
234    /// promoted run describe the promoted attempt alone, this accumulates
235    /// across every attempt of one solve. That is deliberate: the reason
236    /// a second solve happened at all is a fact about the *solve*, and a
237    /// promoted run that reported `false` here would say the retry's
238    /// answer came from nowhere.
239    pub dual_divergence_signature: bool,
240    /// gh#884. A dual-divergence retry ran *and* replaced the base
241    /// attempt's answer. `false` both when no retry ran and when one ran
242    /// and lost — in the latter case the returned point, status and
243    /// residuals are the base attempt's.
244    pub dual_divergence_retry_promoted: bool,
245
246    // ---- Active-set SQP subproblem counters. ----
247    //
248    // Populated by `IpoptApplication::optimize_sqp_tnlp`; both stay 0
249    // on the interior-point path, which has no QP subproblems.
250    //
251    /// Number of QP subproblems solved during this solve.
252    pub sqp_qp_solves: Index,
253    /// Active-set changes (adds + drops) summed over those QP
254    /// subproblems. This is the measurement a working-set warm start
255    /// is judged on: the outer iteration count can be identical
256    /// between a cold and a warm solve while this differs by an order
257    /// of magnitude, and on a QP-shaped NLP (one outer iteration by
258    /// construction) it is the only thing that moves at all.
259    pub sqp_qp_working_set_changes: Index,
260
261    /// Per-iteration trajectory. Empty when the consumer doesn't ask
262    /// for it (`iter_history_enabled = false` on the application or
263    /// the binary's `--json-detail summary` mode). Populated in order
264    /// by [`IpoptAlgorithm::iterate`] when enabled.
265    pub iterations: Vec<IterRecord>,
266}
267
268/// The eight residual fields default to **NaN, not zero**.
269///
270/// They are populated by the convergence check at the end of a solve. A solve
271/// that never gets that far -- rejected during setup (`Not_Enough_Degrees_Of_Freedom`,
272/// `Invalid_Problem_Definition`), aborted, or caught by the batch panic
273/// handler -- leaves them untouched, and a default of `0.0` there reads as
274/// "converged perfectly" rather than "never computed".
275///
276/// That is not hypothetical. `pounce.minimize` upgrades a non-success status
277/// to `success=True` when the final KKT error is within the acceptable
278/// tolerance, which is right for a solve that stalled near a good point. With
279/// a zero default it also fired for problems the solver had *refused*: an
280/// over-determined NLP returned `Not_Enough_Degrees_Of_Freedom` together with
281/// `success=True` and an `x` outside its own variable bounds. NaN makes the
282/// existing `is_finite` guard on that path do what its comment already claims.
283///
284/// Consequences worth knowing:
285///
286/// * NaN compares false against everything, so any `residual <= tol` test now
287///   fails closed for an uncomputed value. That is the intent.
288/// * `serde_json` renders non-finite floats as `null`, so these fields appear
289///   as `null` rather than `0.0` in a solve report for an aborted solve. See
290///   `docs/src/schema/solve-report-v1.md`.
291///
292/// The two objective fields are in the set for the same reason, though the
293/// stakes are lower: nothing *decides* anything from them, they are only
294/// reported (console summary, studio markdown, the JSON report). But `0.0` is
295/// a perfectly ordinary objective value, so a reader cannot tell a solve that
296/// legitimately reached zero from one that never evaluated anything. One rule
297/// -- uncomputed is NaN -- is easier to reason about than "residuals are NaN,
298/// objectives are zero, and you have to remember which is which". Note they
299/// are seeded best-effort from the current iterate whenever one exists, so
300/// they are only NaN when the solve died before producing any point at all.
301///
302/// `final_mu` is deliberately *not* in this set: `0.0` is its documented value
303/// on the barrier-free SQP path, where mu has no meaning.
304impl Default for SolveStatistics {
305    fn default() -> Self {
306        Self {
307            iteration_count: 0,
308            total_cpu_time_secs: 0.0,
309            total_sys_time_secs: 0.0,
310            total_wallclock_time_secs: 0.0,
311            num_obj_evals: 0,
312            num_constr_evals: 0,
313            num_obj_grad_evals: 0,
314            num_constr_jac_evals: 0,
315            num_hess_evals: 0,
316            final_objective: Number::NAN,
317            final_scaled_objective: Number::NAN,
318            final_dual_inf: Number::NAN,
319            final_constr_viol: Number::NAN,
320            final_compl: Number::NAN,
321            final_kkt_error: Number::NAN,
322            final_unscaled_dual_inf: Number::NAN,
323            final_declared_constr_viol: Number::NAN,
324            final_declared_box_viol: Number::NAN,
325            final_unscaled_constr_viol: Number::NAN,
326            final_unscaled_compl: Number::NAN,
327            final_unscaled_kkt_error: Number::NAN,
328            final_kkt_error_above_noise: Number::NAN,
329            final_mu: 0.0,
330            // -1 = the finite-difference updater never built a pattern,
331            // which is every run that is not `hessian_approximation=
332            // finite-difference`. Distinct from 0, a real pattern source.
333            fd_hessian_pattern_used: -1,
334            fd_hessian_nnz: 0,
335            fd_hessian_n: 0,
336            fd_hessian_groups: 0,
337            fd_hessian_rho_max: 0,
338            fd_hessian_coloring_fell_back: false,
339            fd_hessian_objective_clique_widened: false,
340            restoration_calls: 0,
341            restoration_inner_iters: 0,
342            restoration_outer_iters: 0,
343            restoration_wall_secs: 0.0,
344            quality_escalations: 0,
345            dual_divergence_signature: false,
346            dual_divergence_retry_promoted: false,
347            sqp_qp_solves: 0,
348            sqp_qp_working_set_changes: 0,
349            iterations: Vec::new(),
350        }
351    }
352}
353
354impl SolveStatistics {
355    pub fn new() -> Self {
356        Self::default()
357    }
358}