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    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}