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 pub final_unscaled_dual_inf: Number,
75 pub final_unscaled_constr_viol: Number,
76 pub final_unscaled_compl: Number,
77 pub final_unscaled_kkt_error: Number,
78 /// `final_kkt_error` with each constraint row's residual counted only
79 /// where it rises above what that row can represent in floating point —
80 /// the aggregate the **strict** convergence gate actually tests (gh #528).
81 /// Equal to `final_kkt_error` on every problem whose data is `O(1)`, and
82 /// smaller only where a row is at its own resolution limit. Reported so a
83 /// summary that ends `EXIT: Optimal Solution Found` beside an error above
84 /// `tol` accounts for the gap rather than merely presenting it.
85 pub final_kkt_error_above_noise: Number,
86 /// Final barrier parameter μ at termination (the IPM's `curr_mu`
87 /// after the last iterate). Lets a caller thread the converged
88 /// barrier into a warm-started re-solve's `mu_init` /
89 /// `warm_start_target_mu` for predictor–corrector path following
90 /// (pounce#86). `0.0` on the barrier-free SQP path, where μ has
91 /// no meaning.
92 pub final_mu: Number,
93
94 // ---- Restoration-phase audit counters (pounce#12). ----
95 //
96 // Populated by `IpoptApplication::optimize_constrained` after a
97 // solve completes. All three are 0 when restoration never fires.
98 //
99 /// Number of times `IpoptAlgorithm::invoke_restoration` was
100 /// entered during this solve.
101 pub restoration_calls: Index,
102 /// Cumulative inner-IPM iteration count across every restoration
103 /// call (sum of `RestoSolveResult::iter_count`). Each restoration
104 /// call's inner IPM runs to its own convergence; this is the
105 /// total work the inner solver did.
106 pub restoration_inner_iters: Index,
107 /// Number of outer iterations that ran in restoration mode (the
108 /// `r`-suffix iter lines visible in `print_level=5` output).
109 /// Counts outer iters where the IPM was driving a restoration
110 /// trial step rather than a normal Newton step.
111 pub restoration_outer_iters: Index,
112 /// Cumulative wall-clock seconds spent inside `perform_restoration`
113 /// across all restoration calls. Useful for "what fraction of the
114 /// solve was restoration?" without running with high print_level.
115 pub restoration_wall_secs: Number,
116
117 // ---- Active-set SQP subproblem counters. ----
118 //
119 // Populated by `IpoptApplication::optimize_sqp_tnlp`; both stay 0
120 // on the interior-point path, which has no QP subproblems.
121 //
122 /// Number of QP subproblems solved during this solve.
123 pub sqp_qp_solves: Index,
124 /// Active-set changes (adds + drops) summed over those QP
125 /// subproblems. This is the measurement a working-set warm start
126 /// is judged on: the outer iteration count can be identical
127 /// between a cold and a warm solve while this differs by an order
128 /// of magnitude, and on a QP-shaped NLP (one outer iteration by
129 /// construction) it is the only thing that moves at all.
130 pub sqp_qp_working_set_changes: Index,
131
132 /// Per-iteration trajectory. Empty when the consumer doesn't ask
133 /// for it (`iter_history_enabled = false` on the application or
134 /// the binary's `--json-detail summary` mode). Populated in order
135 /// by [`IpoptAlgorithm::iterate`] when enabled.
136 pub iterations: Vec<IterRecord>,
137}
138
139/// The eight residual fields default to **NaN, not zero**.
140///
141/// They are populated by the convergence check at the end of a solve. A solve
142/// that never gets that far -- rejected during setup (`Not_Enough_Degrees_Of_Freedom`,
143/// `Invalid_Problem_Definition`), aborted, or caught by the batch panic
144/// handler -- leaves them untouched, and a default of `0.0` there reads as
145/// "converged perfectly" rather than "never computed".
146///
147/// That is not hypothetical. `pounce.minimize` upgrades a non-success status
148/// to `success=True` when the final KKT error is within the acceptable
149/// tolerance, which is right for a solve that stalled near a good point. With
150/// a zero default it also fired for problems the solver had *refused*: an
151/// over-determined NLP returned `Not_Enough_Degrees_Of_Freedom` together with
152/// `success=True` and an `x` outside its own variable bounds. NaN makes the
153/// existing `is_finite` guard on that path do what its comment already claims.
154///
155/// Consequences worth knowing:
156///
157/// * NaN compares false against everything, so any `residual <= tol` test now
158/// fails closed for an uncomputed value. That is the intent.
159/// * `serde_json` renders non-finite floats as `null`, so these fields appear
160/// as `null` rather than `0.0` in a solve report for an aborted solve. See
161/// `docs/src/schema/solve-report-v1.md`.
162///
163/// The two objective fields are in the set for the same reason, though the
164/// stakes are lower: nothing *decides* anything from them, they are only
165/// reported (console summary, studio markdown, the JSON report). But `0.0` is
166/// a perfectly ordinary objective value, so a reader cannot tell a solve that
167/// legitimately reached zero from one that never evaluated anything. One rule
168/// -- uncomputed is NaN -- is easier to reason about than "residuals are NaN,
169/// objectives are zero, and you have to remember which is which". Note they
170/// are seeded best-effort from the current iterate whenever one exists, so
171/// they are only NaN when the solve died before producing any point at all.
172///
173/// `final_mu` is deliberately *not* in this set: `0.0` is its documented value
174/// on the barrier-free SQP path, where mu has no meaning.
175impl Default for SolveStatistics {
176 fn default() -> Self {
177 Self {
178 iteration_count: 0,
179 total_cpu_time_secs: 0.0,
180 total_sys_time_secs: 0.0,
181 total_wallclock_time_secs: 0.0,
182 num_obj_evals: 0,
183 num_constr_evals: 0,
184 num_obj_grad_evals: 0,
185 num_constr_jac_evals: 0,
186 num_hess_evals: 0,
187 final_objective: Number::NAN,
188 final_scaled_objective: Number::NAN,
189 final_dual_inf: Number::NAN,
190 final_constr_viol: Number::NAN,
191 final_compl: Number::NAN,
192 final_kkt_error: Number::NAN,
193 final_unscaled_dual_inf: Number::NAN,
194 final_unscaled_constr_viol: Number::NAN,
195 final_unscaled_compl: Number::NAN,
196 final_unscaled_kkt_error: Number::NAN,
197 final_kkt_error_above_noise: Number::NAN,
198 final_mu: 0.0,
199 restoration_calls: 0,
200 restoration_inner_iters: 0,
201 restoration_outer_iters: 0,
202 restoration_wall_secs: 0.0,
203 sqp_qp_solves: 0,
204 sqp_qp_working_set_changes: 0,
205 iterations: Vec::new(),
206 }
207 }
208}
209
210impl SolveStatistics {
211 pub fn new() -> Self {
212 Self::default()
213 }
214}