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}