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 exactly as the console table prints it in
28    /// the `inf_pr` column: under the default `inf_pr_output=original`,
29    /// the unscaled constraint violation of the user's model (for
30    /// restoration rows, of the *original* NLP at the restoration
31    /// iterate's `x`). See [`Self::inf_pr_internal`] for the residual
32    /// the filter and the convergence test read (gh#979).
33    pub inf_pr: Number,
34    /// The algorithm's internal primal infeasibility `‖(c(x), d(x) − s)‖∞`
35    /// in the scaled slack form, with the slack pushed off its bound —
36    /// what the filter, the convergence test and the `intermediate`
37    /// callback's `inf_pr` see. On restoration rows it is the
38    /// restoration NLP's own residual. Differs from [`Self::inf_pr`]
39    /// whenever a slack sits away from `d(x)` or scaling is active.
40    #[cfg_attr(feature = "serde", serde(default))]
41    pub inf_pr_internal: Number,
42    /// Dual infeasibility (max-norm of grad-Lagrangian).
43    pub inf_du: Number,
44    /// Barrier parameter μ.
45    pub mu: Number,
46    /// `||d_xs||_∞` of the search step. `0.0` on iter 0 (no step yet).
47    pub d_norm: Number,
48    /// Hessian regularization `δ_w` applied this iter; `0.0` when
49    /// no regularization was needed (printed as `-` in the console).
50    pub regularization: Number,
51    /// Dual step length.
52    pub alpha_dual: Number,
53    /// Primal step length.
54    pub alpha_primal: Number,
55    /// Single-character tag for the alpha-primal column (`f`, `h`,
56    /// `r` for restoration etc.) — matches upstream's per-iter tag.
57    pub alpha_primal_char: char,
58    /// Number of backtracking line-search trials this iter.
59    pub ls_trials: Index,
60    /// Which phase printed this row: the main IPM, or the restoration
61    /// sub-solve (the console's `r`-suffixed rows). A restoration row's
62    /// `iter` is the restoration solver's counter, which continues the
63    /// outer count, so the outer row that leaves restoration repeats the
64    /// last restoration row's index — exactly as the console does.
65    #[cfg_attr(feature = "serde", serde(default))]
66    pub phase: IterPhase,
67}
68
69/// Phase of an [`IterRecord`] row (gh#979).
70#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
73pub enum IterPhase {
74    /// A main-phase iteration (including the `R`-tagged row that hands
75    /// over to restoration).
76    #[default]
77    Main,
78    /// An inner iteration of the restoration phase, printed with an `r`
79    /// suffix on the iteration index.
80    Restoration,
81}
82
83impl IterPhase {
84    /// Stable lowercase name, as serialized in the solve report.
85    pub fn as_str(self) -> &'static str {
86        match self {
87            IterPhase::Main => "main",
88            IterPhase::Restoration => "restoration",
89        }
90    }
91
92    /// Inverse of [`Self::as_str`]; unknown names read as [`IterPhase::Main`].
93    pub fn from_name(name: &str) -> Self {
94        match name {
95            "restoration" => IterPhase::Restoration,
96            _ => IterPhase::Main,
97        }
98    }
99}
100
101/// One pass of a multi-pass solve — an ℓ₁ exact-penalty ρ pass, or the
102/// unwrapped attempt that preceded the ℓ₁ fallback (gh#987 item 6).
103///
104/// `SolveStatistics::iteration_count` and `iterations` cover **all** passes;
105/// this is how a reader gets the per-pass split back.
106#[derive(Debug, Clone, Default, PartialEq)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108pub struct SolvePassRecord {
109    /// Penalty parameter ρ of an ℓ₁ pass; `None` for the plain (unwrapped)
110    /// attempt that an `l1_fallback_on_restoration_failure` retry follows.
111    #[cfg_attr(feature = "serde", serde(default))]
112    pub rho: Option<Number>,
113    /// Iterations this pass ran.
114    pub iterations: Index,
115    /// Index into `SolveStatistics::iterations` of this pass's first row
116    /// (rows of all passes are concatenated; each pass restarts its own
117    /// `iter` numbering at 0). Meaningful only when rows were collected.
118    #[cfg_attr(feature = "serde", serde(default))]
119    pub first_row: usize,
120    /// `Σ(p + n)` of the augmented slacks at the pass's end (the BNW
121    /// steering signal); `None` for an unwrapped pass.
122    #[cfg_attr(feature = "serde", serde(default))]
123    pub slack_sum: Option<Number>,
124    /// Max violation of the **model's own** constraints at the pass's end,
125    /// in the model's units; `None` when it could not be measured.
126    #[cfg_attr(feature = "serde", serde(default))]
127    pub constraint_violation: Option<Number>,
128    /// The pass's exit status, as the `ApplicationReturnStatus` variant name.
129    #[cfg_attr(feature = "serde", serde(default))]
130    pub status: String,
131}
132
133/// What the `mu_strategy_fallback` retry (pounce#138 / pounce#748) did, when
134/// it ran (gh#1042 C1).
135///
136/// The retry re-solves with the opposite `mu_strategy` after a first attempt
137/// that ended `Solved_To_Acceptable_Level` or `Maximum_Iterations_Exceeded`,
138/// and promotes the re-solve only on `Solve_Succeeded`. On a promotion every
139/// other field of [`SolveStatistics`] — `iteration_count`,
140/// `restoration_calls`, the residuals — describes the **re-solve alone**, so
141/// without this record a first attempt that got *worse* reads as a large
142/// speed-up. That is how gh#1042's R2b was mis-scored: `pooling_rt2stp`'s
143/// first attempt went `Solve_Succeeded`/492 → `Solved_To_Acceptable_Level`/494
144/// and the report said 492 → 109, the re-solve's count. The second-opinion
145/// ladder's record (`second_opinion` in the solve report, gh#850) closed the
146/// same hole one mechanism over; this closes it for the μ flip.
147#[derive(Debug, Clone, Default, PartialEq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct MuStrategyFallbackRecord {
150    /// The first attempt's verdict, upstream spelling.
151    pub base_status: String,
152    /// Iterations the first attempt spent. Not in
153    /// `SolveStatistics::iteration_count` when the re-solve was promoted.
154    pub base_iteration_count: Index,
155    /// Restoration calls of the first attempt (same caveat).
156    pub base_restoration_calls: Index,
157    /// The first attempt's final objective (unscaled).
158    pub base_objective: Number,
159    /// The `mu_strategy` the re-solve ran under (`adaptive` / `monotone`).
160    pub retry_mu_strategy: String,
161    /// The re-solve's verdict, upstream spelling.
162    pub retry_status: String,
163    /// Iterations the re-solve spent.
164    pub retry_iteration_count: Index,
165    /// The re-solve's answer was returned. When `false` every other field of
166    /// the statistics describes the first attempt, and the re-solve's cost is
167    /// visible only here.
168    pub promoted: bool,
169}
170
171impl MuStrategyFallbackRecord {
172    /// First attempt plus re-solve: what the fallback path actually cost.
173    pub fn total_iteration_count(&self) -> Index {
174        self.base_iteration_count + self.retry_iteration_count
175    }
176}
177
178#[derive(Debug, Clone)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub struct SolveStatistics {
181    pub iteration_count: Index,
182    pub total_cpu_time_secs: Number,
183    pub total_sys_time_secs: Number,
184    pub total_wallclock_time_secs: Number,
185    pub num_obj_evals: Index,
186    pub num_constr_evals: Index,
187    pub num_obj_grad_evals: Index,
188    pub num_constr_jac_evals: Index,
189    pub num_hess_evals: Index,
190    pub final_objective: Number,
191    pub final_scaled_objective: Number,
192    pub final_dual_inf: Number,
193    pub final_constr_viol: Number,
194    pub final_compl: Number,
195    pub final_kkt_error: Number,
196    // Unscaled (user-original-space) counterparts of the four residuals
197    // above. The `final_*` fields are max-norms in the internally-scaled
198    // NLP space (objective × df, constraints × dc); these divide the
199    // nlp_scaling back out so a consumer can verify a returned KKT
200    // certificate in its own units. Equal to the scaled fields when no
201    // nlp_scaling is active. `final_unscaled_kkt_error` is the plain
202    // max-norm of the three (no s_d/s_c optimality scaling). (pounce#173)
203    /// Primal violation measured against the model **as declared**, before
204    /// the `bound_relax_factor` widening the convex arm applies
205    /// (`qp_extract::BoundRelax`, gh #744/#745).
206    ///
207    /// `final_constr_viol` measures the model the solver was HANDED, whose
208    /// inequality rows and variable box are widened by
209    /// `min(factor, cap)·|b|`. That is the right model for the convergence
210    /// test, and it is what every acceptance gate reads — but it is not how
211    /// far the returned point sits outside the model the caller wrote. On
212    /// netlib `afiro` the point is `4.99e-06` outside a declared row
213    /// `b = 500` (exactly `1e-8·500`) while `final_constr_viol` reads
214    /// `8.68e-13`; on `25fv47` it is `1.97e-05` against `2.19e-11`.
215    ///
216    /// Reported so a caller can tell the two apart rather than reading the
217    /// widened number as its own model's feasibility. Reported on every
218    /// NLP-arm solve, widened or not, in the caller's units (gh#987 — it is
219    /// no longer `NaN` as a "no widening" signal); under
220    /// `l1_exact_penalty_barrier` it is measured on the caller's own rows,
221    /// not the wrapper's augmented ones. `NaN` on paths that do not compute
222    /// it, or when the model could not be evaluated at the returned point.
223    pub final_declared_constr_viol: Number,
224    /// How far the returned point sits outside the **declared** variable box
225    /// — the box the caller wrote, before the `bound_relax_factor` widening.
226    /// This is Ipopt's `Variable bound violation`, and it is the box half of
227    /// [`Self::final_declared_constr_viol`] reported on its own, because once
228    /// the two are maxed together a box violation cannot be told from a row
229    /// violation.
230    ///
231    /// Variable bounds carry no scaling — POUNCE scales the objective and the
232    /// constraint rows only — so there is no scaled/unscaled pair here; the
233    /// one number is right in both columns.
234    ///
235    /// `NaN` on a path that does not compute it.
236    pub final_declared_box_viol: Number,
237    pub final_unscaled_dual_inf: Number,
238    /// gh#983. Magnitude of the terms the unscaled stationarity residual is
239    /// assembled from (`max |∇f|, |Jᵀλ|, |z|` in user units), the yardstick
240    /// `final_unscaled_dual_inf` is judged against by the scale-relative
241    /// floor (gh#532) and by the solve-quality warnings. `NaN` on a path that
242    /// does not compute it.
243    pub final_unscaled_dual_scale: Number,
244    /// gh#983. The solver-computed (gradient-based) objective scaling factor
245    /// the run ended with, excluding the caller's own `obj_scaling_factor`;
246    /// `1` when none was applied, `NaN` on a path that does not compute it.
247    pub final_obj_scaling_factor: Number,
248    /// gh#1001. The magnitude of the whole objective multiplier the run
249    /// ended with: the caller's `obj_scaling_factor`, the gradient-based
250    /// factor, and the gh#983 audit's re-solve upscale, multiplied together.
251    /// The IPM minimized `total · f`, so `final_scaled_objective` and every
252    /// iteration's `mu` are in those units. `NaN` on a path that does not
253    /// compute it.
254    pub final_total_obj_scaling_factor: Number,
255    /// gh#983. `max |grad f|` at the starting point (what gradient-based
256    /// scaling measured); `NaN` when it did not run. The model's own gradient
257    /// scale, used as the yardstick for the small-objective warning.
258    pub start_obj_grad_max: Number,
259    /// gh#983. Structured solve-quality warnings, each `"<code>: <text>"`,
260    /// describing the run whose point is returned (a discarded attempt's
261    /// warnings are dropped with it). Each code appears at most once. Codes:
262    /// `objective_scale_small`, `unscaled_stationarity_above_tol`,
263    /// `large_dual_scale`, `unscaled_dual_inf_above_acceptable`. Empty on a
264    /// clean run. A warning never changes the status by itself; the one
265    /// status change the audit makes (a strict success on a run that showed
266    /// the gh#884 signature and still carries an unscaled dual infeasibility
267    /// above `max(acceptable_tol, 1e-3)` is reported as
268    /// `Solved_To_Acceptable_Level`) is announced by the
269    /// `unscaled_dual_inf_above_acceptable` warning.
270    pub warnings: Vec<String>,
271    /// gh#990 item 3. The derivative checker's machine-readable verdict, when
272    /// `derivative_test` ran for this solve (`None` otherwise). The console
273    /// report on stderr is unchanged.
274    #[cfg_attr(feature = "serde", serde(skip))]
275    pub derivative_check: Option<crate::derivative_test::DerivativeCheckSummary>,
276    /// gh#990 item 13. A strict termination certificate was refused because
277    /// the objective scaling masked it (gh#200): the solve kept iterating
278    /// toward the true minimum. The decision used to be visible only as an INFO
279    /// line on stderr.
280    pub obj_scale_certificate_refused: bool,
281    /// Same, for an acceptable-level certificate.
282    pub obj_scale_acceptable_refused: bool,
283    pub final_unscaled_constr_viol: Number,
284    pub final_unscaled_compl: Number,
285    pub final_unscaled_kkt_error: Number,
286    /// `final_kkt_error` with each constraint row's residual counted only
287    /// where it rises above what that row can represent in floating point —
288    /// the aggregate the **strict** convergence gate actually tests (gh #528).
289    /// Equal to `final_kkt_error` on every problem whose data is `O(1)`, and
290    /// smaller only where a row is at its own resolution limit. Reported so a
291    /// summary that ends `EXIT: Optimal Solution Found` beside an error above
292    /// `tol` accounts for the gap rather than merely presenting it.
293    pub final_kkt_error_above_noise: Number,
294    /// Final barrier parameter μ at termination (the IPM's `curr_mu`
295    /// after the last iterate). Lets a caller thread the converged
296    /// barrier into a warm-started re-solve's `mu_init` /
297    /// `warm_start_target_mu` for predictor–corrector path following
298    /// (pounce#86). `0.0` on the barrier-free SQP path, where μ has
299    /// no meaning.
300    pub final_mu: Number,
301
302    // ---- Restoration-phase audit counters (pounce#12). ----
303    //
304    // Populated by `IpoptApplication::optimize_constrained` after a
305    // solve completes. All three are 0 when restoration never fires.
306    //
307    /// Number of times `IpoptAlgorithm::invoke_restoration` was
308    /// entered during this solve.
309    /// Finite-difference Hessian census, when
310    /// `hessian_approximation=finite-difference` actually built a pattern.
311    /// All zero / `-1` on every other Hessian mode, which is how a caller
312    /// tells "the mode did not run" from "it ran with an empty pattern".
313    ///
314    /// `fd_hessian_pattern_used` is the source the run **ended up with**,
315    /// not the one requested: `0` declared, `1` jacobian, `-1` not run.
316    /// `declared` silently falls back to `jacobian` when the TNLP declares
317    /// no Hessian structure, and that fallback is the difference between
318    /// 17 probe groups and 341 on `benchmarks/large_scale` `laptime`, so
319    /// reporting the request would hide the number a reader is here for.
320    pub fd_hessian_pattern_used: Index,
321    /// Hessian nonzeros in the pattern that was coloured (lower triangle).
322    pub fd_hessian_nnz: Index,
323    /// Columns the colouring ran over, i.e. the problem's variable count.
324    /// Present so the report is self-contained: `groups / n` is the
325    /// compression, the fraction of a dense finite-difference scheme's
326    /// probes this pattern costs, and without `n` a reader cannot form it.
327    pub fd_hessian_n: Index,
328    /// Probe groups per Hessian — the count of extra gradient/Jacobian
329    /// evaluations each rebuild costs.
330    pub fd_hessian_groups: Index,
331    /// Widest row of the pattern; the quantity that decides whether the
332    /// colouring can stay narrow under mesh refinement.
333    pub fd_hessian_rho_max: Index,
334    /// Whether a requested star colouring failed validation and CPR was
335    /// substituted.
336    pub fd_hessian_coloring_fell_back: bool,
337    /// Whether the objective clique fell back to a conservative structural
338    /// set because the model stated no objective linearity. This is the
339    /// field that explains a surprising `fd_hessian_groups`: the clique is
340    /// then `N`, or all `n`, and the probe count reflects that rather than
341    /// the objective's true support.
342    pub fd_hessian_objective_clique_widened: bool,
343    pub restoration_calls: Index,
344    /// Cumulative inner-IPM iterations across every restoration call —
345    /// the number of `r`-suffix rows a `print_level=5` log would show.
346    ///
347    /// Each call contributes its sub-solve's own *length*: the inner
348    /// counter is seeded from the outer's at entry (upstream
349    /// `IpRestoMinC_1Nrm.cpp:181`), so the length is the terminating value
350    /// minus the outer count at entry. Before gh #819 this summed the
351    /// terminating values themselves — absolute positions in a shared
352    /// numbering, not lengths — and recorded `0` for any call that failed,
353    /// which is the case a reader is looking at this field to understand.
354    pub restoration_inner_iters: Index,
355    /// Number of *outer* iterations consumed by restoration: one per call,
356    /// so this always equals `restoration_calls`.
357    ///
358    /// It is not the count of `r`-suffix rows — that is
359    /// `restoration_inner_iters`, and reading this field as those rows is
360    /// what the doc comment here said until gh #819. Restoration in POUNCE
361    /// is a nested solve entered from a single outer iteration, not a mode
362    /// the outer loop runs in, so there is no third number here to report.
363    pub restoration_outer_iters: Index,
364    /// Cumulative wall-clock seconds spent inside `perform_restoration`
365    /// across all restoration calls. Useful for "what fraction of the
366    /// solve was restoration?" without running with high print_level.
367    pub restoration_wall_secs: Number,
368
369    /// Successful linear-solver quality escalations over the whole solve
370    /// — the main loop's and every restoration sub-solve's — i.e. the
371    /// count of `q` flags in the info-string column (gh#857).
372    ///
373    /// An escalation is not an error and not, on its own, a problem: it
374    /// is how the IPM answers a factorization that will not deliver.
375    /// But with the FERAL backend it *reroutes the rest of the solve*,
376    /// because that backend's ladder changes which pivots are taken and
377    /// never steps back down, so a run that ends badly having escalated
378    /// is a different animal from one that ends badly without. Before
379    /// this counter the two were indistinguishable in a report, which is
380    /// why gh#857's regression had to be found by instrumenting a build.
381    ///
382    /// `0` on the SQP and convex paths, which never escalate, and on any
383    /// run whose backend declines to (`increase_quality` returning
384    /// `false` is not counted — this counts escalations that *happened*,
385    /// not escalations that were asked for).
386    ///
387    /// **On a laddered run this is the promoted solve's count, not the
388    /// base solve's** — the same rule `iteration_count` follows, and the
389    /// same trap. It is a sharp edge here because `feral_increase_quality_retry`
390    /// promotes a re-solve that by construction escalated zero times, so
391    /// a run whose base solve escalated twenty-five times reports `0`
392    /// once the recovery lands. That is not a lost number: the ladder
393    /// block records the base verdict alongside it, and
394    /// `feral_increase_quality_retry=no` reproduces the base solve
395    /// outright. The rung's own gate reads the base statistics inside the
396    /// driver, before any promotion, so the gate is unaffected.
397    pub quality_escalations: Index,
398
399    /// gh#1042. This solve ended on the unreleased-hand-back exit: the
400    /// restoration phase handed back a converged-to-feasible point it never
401    /// released, and the outer phase re-entered restoration from that very
402    /// point. The status is upstream's `Restoration_Failed` (or
403    /// `Infeasible_Problem_Detected` under the gh#508 rule), and this flag is
404    /// what lets the second-opinion ladder tell that `Restoration_Failed`
405    /// from every other one: it alone opens the `mu_strategy=adaptive` rung.
406    /// Describes the returned attempt only, like `quality_escalations`.
407    pub unreleased_handback_exit: bool,
408
409    /// gh#884. The solve observed the biactive dual-divergence signature:
410    /// at one and the same iterate, a converged primal
411    /// (`inf_pr <= dual_divergence_retry_primal_tol`), a scale-relative
412    /// step at or below `dual_divergence_retry_step_tol`, and an
413    /// *unscaled* dual infeasibility at or above
414    /// `dual_divergence_retry_du_floor`.
415    ///
416    /// Reported whether or not a retry ran or promoted, so a caller can
417    /// tell "the multipliers ran away on a settled iterate" from an exit
418    /// that merely ran out of iterations.
419    ///
420    /// Unlike `quality_escalations` and `iteration_count`, which on a
421    /// promoted run describe the promoted attempt alone, this accumulates
422    /// across every attempt of one solve. That is deliberate: the reason
423    /// a second solve happened at all is a fact about the *solve*, and a
424    /// promoted run that reported `false` here would say the retry's
425    /// answer came from nowhere.
426    pub dual_divergence_signature: bool,
427    /// gh#983 review item 3. The same detector, for the **one run whose point
428    /// is returned** only. `dual_divergence_signature` accumulates across the
429    /// attempts of a solve on purpose (above); a verdict about the returned
430    /// point must not read a discarded sibling's flag, so the item-5 downgrade
431    /// reads this one. Saved and restored with the rest of the run's tally
432    /// when a retry is declined.
433    #[cfg_attr(feature = "serde", serde(default))]
434    pub returned_run_dual_divergence_signature: bool,
435    /// gh#884. A dual-divergence retry ran *and* replaced the base
436    /// attempt's answer. `false` both when no retry ran and when one ran
437    /// and lost — in the latter case the returned point, status and
438    /// residuals are the base attempt's.
439    pub dual_divergence_retry_promoted: bool,
440    /// gh#1042 C1. The `mu_strategy_fallback` retry's record when it ran in
441    /// this solve, `None` otherwise. Sticky across the attempts of one solve
442    /// (like `dual_divergence_signature`): a later attempt an outer driver
443    /// spends still carries it, because the retry happened.
444    #[cfg_attr(feature = "serde", serde(default))]
445    pub mu_strategy_fallback: Option<MuStrategyFallbackRecord>,
446
447    // ---- Active-set SQP subproblem counters. ----
448    //
449    // Populated by `IpoptApplication::optimize_sqp_tnlp`; both stay 0
450    // on the interior-point path, which has no QP subproblems.
451    //
452    /// Number of QP subproblems solved during this solve.
453    pub sqp_qp_solves: Index,
454    /// Active-set changes (adds + drops) summed over those QP
455    /// subproblems. This is the measurement a working-set warm start
456    /// is judged on: the outer iteration count can be identical
457    /// between a cold and a warm solve while this differs by an order
458    /// of magnitude, and on a QP-shaped NLP (one outer iteration by
459    /// construction) it is the only thing that moves at all.
460    pub sqp_qp_working_set_changes: Index,
461    /// gh#986 review item 10: the SQP solve started from a caller-supplied
462    /// working set (after mapping it through this problem's fixed-variable
463    /// elimination). `false` on a cold solve, on the IPM path, and when a
464    /// supplied working set had to be dropped -- which is also logged.
465    #[cfg_attr(feature = "serde", serde(default))]
466    pub sqp_warm_working_set_applied: bool,
467
468    /// Per-iteration trajectory. Empty when the consumer doesn't ask
469    /// for it (`iter_history_enabled = false` on the application or
470    /// the binary's `--json-detail summary` mode). Populated in order
471    /// by [`IpoptAlgorithm::iterate`] when enabled.
472    pub iterations: Vec<IterRecord>,
473
474    /// Per-pass summary of a multi-pass solve: one entry per ℓ₁
475    /// exact-penalty ρ pass (preceded by the unwrapped attempt when the ℓ₁
476    /// fallback produced them). Empty on an ordinary single-pass solve.
477    ///
478    /// When non-empty, `iteration_count` is the **total** over these passes
479    /// and `iterations` is their rows concatenated (gh#987 item 6); every
480    /// other `final_*` field describes the last pass, which produced the
481    /// returned point.
482    pub passes: Vec<SolvePassRecord>,
483}
484
485/// The eight residual fields default to **NaN, not zero**.
486///
487/// They are populated by the convergence check at the end of a solve. A solve
488/// that never gets that far -- rejected during setup (`Not_Enough_Degrees_Of_Freedom`,
489/// `Invalid_Problem_Definition`), aborted, or caught by the batch panic
490/// handler -- leaves them untouched, and a default of `0.0` there reads as
491/// "converged perfectly" rather than "never computed".
492///
493/// That is not hypothetical. `pounce.minimize` upgrades a non-success status
494/// to `success=True` when the final KKT error is within the acceptable
495/// tolerance, which is right for a solve that stalled near a good point. With
496/// a zero default it also fired for problems the solver had *refused*: an
497/// over-determined NLP returned `Not_Enough_Degrees_Of_Freedom` together with
498/// `success=True` and an `x` outside its own variable bounds. NaN makes the
499/// existing `is_finite` guard on that path do what its comment already claims.
500///
501/// Consequences worth knowing:
502///
503/// * NaN compares false against everything, so any `residual <= tol` test now
504///   fails closed for an uncomputed value. That is the intent.
505/// * `serde_json` renders non-finite floats as `null`, so these fields appear
506///   as `null` rather than `0.0` in a solve report for an aborted solve. See
507///   `docs/src/schema/solve-report-v1.md`.
508///
509/// The two objective fields are in the set for the same reason, though the
510/// stakes are lower: nothing *decides* anything from them, they are only
511/// reported (console summary, studio markdown, the JSON report). But `0.0` is
512/// a perfectly ordinary objective value, so a reader cannot tell a solve that
513/// legitimately reached zero from one that never evaluated anything. One rule
514/// -- uncomputed is NaN -- is easier to reason about than "residuals are NaN,
515/// objectives are zero, and you have to remember which is which". Note they
516/// are seeded best-effort from the current iterate whenever one exists, so
517/// they are only NaN when the solve died before producing any point at all.
518///
519/// `final_mu` is deliberately *not* in this set: `0.0` is its documented value
520/// on the barrier-free SQP path, where mu has no meaning.
521impl Default for SolveStatistics {
522    fn default() -> Self {
523        Self {
524            iteration_count: 0,
525            total_cpu_time_secs: 0.0,
526            total_sys_time_secs: 0.0,
527            total_wallclock_time_secs: 0.0,
528            num_obj_evals: 0,
529            num_constr_evals: 0,
530            num_obj_grad_evals: 0,
531            num_constr_jac_evals: 0,
532            num_hess_evals: 0,
533            final_objective: Number::NAN,
534            final_scaled_objective: Number::NAN,
535            final_dual_inf: Number::NAN,
536            final_constr_viol: Number::NAN,
537            final_compl: Number::NAN,
538            final_kkt_error: Number::NAN,
539            final_unscaled_dual_inf: Number::NAN,
540            final_unscaled_dual_scale: Number::NAN,
541            final_obj_scaling_factor: Number::NAN,
542            final_total_obj_scaling_factor: Number::NAN,
543            start_obj_grad_max: Number::NAN,
544            warnings: Vec::new(),
545            derivative_check: None,
546            obj_scale_certificate_refused: false,
547            obj_scale_acceptable_refused: false,
548            final_declared_constr_viol: Number::NAN,
549            final_declared_box_viol: Number::NAN,
550            final_unscaled_constr_viol: Number::NAN,
551            final_unscaled_compl: Number::NAN,
552            final_unscaled_kkt_error: Number::NAN,
553            final_kkt_error_above_noise: Number::NAN,
554            final_mu: 0.0,
555            // -1 = the finite-difference updater never built a pattern,
556            // which is every run that is not `hessian_approximation=
557            // finite-difference`. Distinct from 0, a real pattern source.
558            fd_hessian_pattern_used: -1,
559            fd_hessian_nnz: 0,
560            fd_hessian_n: 0,
561            fd_hessian_groups: 0,
562            fd_hessian_rho_max: 0,
563            fd_hessian_coloring_fell_back: false,
564            fd_hessian_objective_clique_widened: false,
565            restoration_calls: 0,
566            restoration_inner_iters: 0,
567            restoration_outer_iters: 0,
568            restoration_wall_secs: 0.0,
569            quality_escalations: 0,
570            unreleased_handback_exit: false,
571            dual_divergence_signature: false,
572            returned_run_dual_divergence_signature: false,
573            dual_divergence_retry_promoted: false,
574            mu_strategy_fallback: None,
575            sqp_qp_solves: 0,
576            sqp_qp_working_set_changes: 0,
577            sqp_warm_working_set_applied: false,
578            iterations: Vec::new(),
579            passes: Vec::new(),
580        }
581    }
582}
583
584impl SolveStatistics {
585    pub fn new() -> Self {
586        Self::default()
587    }
588
589    /// Every iteration this solve spent: [`Self::iteration_count`] plus the
590    /// `mu_strategy_fallback` re-solve's, when one ran. `iteration_count`
591    /// alone is one attempt's: the re-solve's when it was promoted, the
592    /// first attempt's when it lost. Either way the other attempt's cost
593    /// lives only in [`Self::mu_strategy_fallback`], so a total built from
594    /// `iteration_count` silently drops it. That is what the second-opinion
595    /// ladder's `total_iteration_count` did: on `square_flowsheet_resto`'s
596    /// lbfgs leg, rung 4's re-solve hit the cap at 500 and its flip spent 82
597    /// more, and the report said 812 for a solve that cost 894.
598    pub fn spent_iteration_count(&self) -> Index {
599        self.mu_strategy_fallback
600            .as_ref()
601            .map_or(self.iteration_count, |r| r.total_iteration_count())
602    }
603}
604
605#[cfg(test)]
606mod spent_iteration_count_tests {
607    use super::*;
608
609    fn with_fallback(
610        iteration_count: Index,
611        base: Index,
612        retry: Index,
613        promoted: bool,
614    ) -> SolveStatistics {
615        SolveStatistics {
616            iteration_count,
617            mu_strategy_fallback: Some(MuStrategyFallbackRecord {
618                base_iteration_count: base,
619                retry_iteration_count: retry,
620                promoted,
621                ..Default::default()
622            }),
623            ..Default::default()
624        }
625    }
626
627    #[test]
628    fn without_a_fallback_it_is_the_iteration_count() {
629        let s = SolveStatistics {
630            iteration_count: 42,
631            ..Default::default()
632        };
633        assert_eq!(s.spent_iteration_count(), 42);
634    }
635
636    /// A lost re-solve: `iteration_count` is the first attempt's, and the
637    /// re-solve's iterations are the ones a total built from it dropped.
638    #[test]
639    fn a_lost_re_solve_is_counted() {
640        assert_eq!(
641            with_fallback(500, 500, 82, false).spent_iteration_count(),
642            582
643        );
644    }
645
646    /// A promoted re-solve: `iteration_count` is the re-solve's, and the
647    /// first attempt's iterations are the ones dropped.
648    #[test]
649    fn a_promoted_re_solve_counts_the_first_attempt_too() {
650        assert_eq!(
651            with_fallback(109, 492, 109, true).spent_iteration_count(),
652            601
653        );
654    }
655}