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}