pounce-nlp 0.13.0

NLP-side glue for POUNCE (port of Ipopt's src/Interfaces): TNLP trait, TNLPAdapter, NLP / IpoptNLP wrappers, return-code enums, IpoptApplication user-facing entry point.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
//! Per-solve counters and timers.
//!
//! Mirrors `Interfaces/IpSolveStatistics.{hpp,cpp}`. Values are
//! populated by `IpoptApplication` after a successful solve. This is
//! a Phase-3 skeleton — the cumulative timer bookkeeping is wired up
//! in Phase 7 once `IpoptAlg` is producing iterations.

use pounce_common::types::{Index, Number};

/// One row of per-iteration data — same numbers that
/// `IpoptAlgorithm` prints to stdout each iteration (the "iter
/// objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls"
/// line). Captured into [`SolveStatistics::iterations`] when a
/// JSON / programmatic consumer needs the trajectory rather than
/// just the final state.
///
/// Field semantics mirror upstream `IpOrigIterationOutput.cpp:152`
/// (`Snprintf` block) so a row in JSON round-trips back into the
/// same console table verbatim.
#[derive(Debug, Default, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IterRecord {
    /// Iteration index, starting at 0.
    pub iter: Index,
    /// Unscaled objective `f(x_k)` at the start of iter `k`.
    pub objective: Number,
    /// Primal infeasibility exactly as the console table prints it in
    /// the `inf_pr` column: under the default `inf_pr_output=original`,
    /// the unscaled constraint violation of the user's model (for
    /// restoration rows, of the *original* NLP at the restoration
    /// iterate's `x`). See [`Self::inf_pr_internal`] for the residual
    /// the filter and the convergence test read (gh#979).
    pub inf_pr: Number,
    /// The algorithm's internal primal infeasibility `‖(c(x), d(x) − s)‖∞`
    /// in the scaled slack form, with the slack pushed off its bound —
    /// what the filter, the convergence test and the `intermediate`
    /// callback's `inf_pr` see. On restoration rows it is the
    /// restoration NLP's own residual. Differs from [`Self::inf_pr`]
    /// whenever a slack sits away from `d(x)` or scaling is active.
    #[cfg_attr(feature = "serde", serde(default))]
    pub inf_pr_internal: Number,
    /// Dual infeasibility (max-norm of grad-Lagrangian).
    pub inf_du: Number,
    /// Barrier parameter μ.
    pub mu: Number,
    /// `||d_xs||_∞` of the search step. `0.0` on iter 0 (no step yet).
    pub d_norm: Number,
    /// Hessian regularization `δ_w` applied this iter; `0.0` when
    /// no regularization was needed (printed as `-` in the console).
    pub regularization: Number,
    /// Dual step length.
    pub alpha_dual: Number,
    /// Primal step length.
    pub alpha_primal: Number,
    /// Single-character tag for the alpha-primal column (`f`, `h`,
    /// `r` for restoration etc.) — matches upstream's per-iter tag.
    pub alpha_primal_char: char,
    /// Number of backtracking line-search trials this iter.
    pub ls_trials: Index,
    /// Which phase printed this row: the main IPM, or the restoration
    /// sub-solve (the console's `r`-suffixed rows). A restoration row's
    /// `iter` is the restoration solver's counter, which continues the
    /// outer count, so the outer row that leaves restoration repeats the
    /// last restoration row's index — exactly as the console does.
    #[cfg_attr(feature = "serde", serde(default))]
    pub phase: IterPhase,
}

/// Phase of an [`IterRecord`] row (gh#979).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum IterPhase {
    /// A main-phase iteration (including the `R`-tagged row that hands
    /// over to restoration).
    #[default]
    Main,
    /// An inner iteration of the restoration phase, printed with an `r`
    /// suffix on the iteration index.
    Restoration,
}

impl IterPhase {
    /// Stable lowercase name, as serialized in the solve report.
    pub fn as_str(self) -> &'static str {
        match self {
            IterPhase::Main => "main",
            IterPhase::Restoration => "restoration",
        }
    }

    /// Inverse of [`Self::as_str`]; unknown names read as [`IterPhase::Main`].
    pub fn from_name(name: &str) -> Self {
        match name {
            "restoration" => IterPhase::Restoration,
            _ => IterPhase::Main,
        }
    }
}

/// One pass of a multi-pass solve — an ℓ₁ exact-penalty ρ pass, or the
/// unwrapped attempt that preceded the ℓ₁ fallback (gh#987 item 6).
///
/// `SolveStatistics::iteration_count` and `iterations` cover **all** passes;
/// this is how a reader gets the per-pass split back.
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SolvePassRecord {
    /// Penalty parameter ρ of an ℓ₁ pass; `None` for the plain (unwrapped)
    /// attempt that an `l1_fallback_on_restoration_failure` retry follows.
    #[cfg_attr(feature = "serde", serde(default))]
    pub rho: Option<Number>,
    /// Iterations this pass ran.
    pub iterations: Index,
    /// Index into `SolveStatistics::iterations` of this pass's first row
    /// (rows of all passes are concatenated; each pass restarts its own
    /// `iter` numbering at 0). Meaningful only when rows were collected.
    #[cfg_attr(feature = "serde", serde(default))]
    pub first_row: usize,
    /// `Σ(p + n)` of the augmented slacks at the pass's end (the BNW
    /// steering signal); `None` for an unwrapped pass.
    #[cfg_attr(feature = "serde", serde(default))]
    pub slack_sum: Option<Number>,
    /// Max violation of the **model's own** constraints at the pass's end,
    /// in the model's units; `None` when it could not be measured.
    #[cfg_attr(feature = "serde", serde(default))]
    pub constraint_violation: Option<Number>,
    /// The pass's exit status, as the `ApplicationReturnStatus` variant name.
    #[cfg_attr(feature = "serde", serde(default))]
    pub status: String,
}

#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SolveStatistics {
    pub iteration_count: Index,
    pub total_cpu_time_secs: Number,
    pub total_sys_time_secs: Number,
    pub total_wallclock_time_secs: Number,
    pub num_obj_evals: Index,
    pub num_constr_evals: Index,
    pub num_obj_grad_evals: Index,
    pub num_constr_jac_evals: Index,
    pub num_hess_evals: Index,
    pub final_objective: Number,
    pub final_scaled_objective: Number,
    pub final_dual_inf: Number,
    pub final_constr_viol: Number,
    pub final_compl: Number,
    pub final_kkt_error: Number,
    // Unscaled (user-original-space) counterparts of the four residuals
    // above. The `final_*` fields are max-norms in the internally-scaled
    // NLP space (objective × df, constraints × dc); these divide the
    // nlp_scaling back out so a consumer can verify a returned KKT
    // certificate in its own units. Equal to the scaled fields when no
    // nlp_scaling is active. `final_unscaled_kkt_error` is the plain
    // max-norm of the three (no s_d/s_c optimality scaling). (pounce#173)
    /// Primal violation measured against the model **as declared**, before
    /// the `bound_relax_factor` widening the convex arm applies
    /// (`qp_extract::BoundRelax`, gh #744/#745).
    ///
    /// `final_constr_viol` measures the model the solver was HANDED, whose
    /// inequality rows and variable box are widened by
    /// `min(factor, cap)·|b|`. That is the right model for the convergence
    /// test, and it is what every acceptance gate reads — but it is not how
    /// far the returned point sits outside the model the caller wrote. On
    /// netlib `afiro` the point is `4.99e-06` outside a declared row
    /// `b = 500` (exactly `1e-8·500`) while `final_constr_viol` reads
    /// `8.68e-13`; on `25fv47` it is `1.97e-05` against `2.19e-11`.
    ///
    /// Reported so a caller can tell the two apart rather than reading the
    /// widened number as its own model's feasibility. Reported on every
    /// NLP-arm solve, widened or not, in the caller's units (gh#987 — it is
    /// no longer `NaN` as a "no widening" signal); under
    /// `l1_exact_penalty_barrier` it is measured on the caller's own rows,
    /// not the wrapper's augmented ones. `NaN` on paths that do not compute
    /// it, or when the model could not be evaluated at the returned point.
    pub final_declared_constr_viol: Number,
    /// How far the returned point sits outside the **declared** variable box
    /// — the box the caller wrote, before the `bound_relax_factor` widening.
    /// This is Ipopt's `Variable bound violation`, and it is the box half of
    /// [`Self::final_declared_constr_viol`] reported on its own, because once
    /// the two are maxed together a box violation cannot be told from a row
    /// violation.
    ///
    /// Variable bounds carry no scaling — POUNCE scales the objective and the
    /// constraint rows only — so there is no scaled/unscaled pair here; the
    /// one number is right in both columns.
    ///
    /// `NaN` on a path that does not compute it.
    pub final_declared_box_viol: Number,
    pub final_unscaled_dual_inf: Number,
    /// gh#983. Magnitude of the terms the unscaled stationarity residual is
    /// assembled from (`max |∇f|, |Jᵀλ|, |z|` in user units), the yardstick
    /// `final_unscaled_dual_inf` is judged against by the scale-relative
    /// floor (gh#532) and by the solve-quality warnings. `NaN` on a path that
    /// does not compute it.
    pub final_unscaled_dual_scale: Number,
    /// gh#983. The solver-computed (gradient-based) objective scaling factor
    /// the run ended with, excluding the caller's own `obj_scaling_factor`;
    /// `1` when none was applied, `NaN` on a path that does not compute it.
    pub final_obj_scaling_factor: Number,
    /// gh#1001. The magnitude of the whole objective multiplier the run
    /// ended with: the caller's `obj_scaling_factor`, the gradient-based
    /// factor, and the gh#983 audit's re-solve upscale, multiplied together.
    /// The IPM minimized `total · f`, so `final_scaled_objective` and every
    /// iteration's `mu` are in those units. `NaN` on a path that does not
    /// compute it.
    pub final_total_obj_scaling_factor: Number,
    /// gh#983. `max |grad f|` at the starting point (what gradient-based
    /// scaling measured); `NaN` when it did not run. The model's own gradient
    /// scale, used as the yardstick for the small-objective warning.
    pub start_obj_grad_max: Number,
    /// gh#983. Structured solve-quality warnings, each `"<code>: <text>"`,
    /// describing the run whose point is returned (a discarded attempt's
    /// warnings are dropped with it). Each code appears at most once. Codes:
    /// `objective_scale_small`, `unscaled_stationarity_above_tol`,
    /// `large_dual_scale`, `unscaled_dual_inf_above_acceptable`. Empty on a
    /// clean run. A warning never changes the status by itself; the one
    /// status change the audit makes (a strict success on a run that showed
    /// the gh#884 signature and still carries an unscaled dual infeasibility
    /// above `max(acceptable_tol, 1e-3)` is reported as
    /// `Solved_To_Acceptable_Level`) is announced by the
    /// `unscaled_dual_inf_above_acceptable` warning.
    pub warnings: Vec<String>,
    /// gh#990 item 3. The derivative checker's machine-readable verdict, when
    /// `derivative_test` ran for this solve (`None` otherwise). The console
    /// report on stderr is unchanged.
    #[cfg_attr(feature = "serde", serde(skip))]
    pub derivative_check: Option<crate::derivative_test::DerivativeCheckSummary>,
    /// gh#990 item 13. A strict termination certificate was refused because
    /// the objective scaling masked it (gh#200): the solve kept iterating
    /// toward the true minimum. The decision used to be visible only as an INFO
    /// line on stderr.
    pub obj_scale_certificate_refused: bool,
    /// Same, for an acceptable-level certificate.
    pub obj_scale_acceptable_refused: bool,
    pub final_unscaled_constr_viol: Number,
    pub final_unscaled_compl: Number,
    pub final_unscaled_kkt_error: Number,
    /// `final_kkt_error` with each constraint row's residual counted only
    /// where it rises above what that row can represent in floating point —
    /// the aggregate the **strict** convergence gate actually tests (gh #528).
    /// Equal to `final_kkt_error` on every problem whose data is `O(1)`, and
    /// smaller only where a row is at its own resolution limit. Reported so a
    /// summary that ends `EXIT: Optimal Solution Found` beside an error above
    /// `tol` accounts for the gap rather than merely presenting it.
    pub final_kkt_error_above_noise: Number,
    /// Final barrier parameter μ at termination (the IPM's `curr_mu`
    /// after the last iterate). Lets a caller thread the converged
    /// barrier into a warm-started re-solve's `mu_init` /
    /// `warm_start_target_mu` for predictor–corrector path following
    /// (pounce#86). `0.0` on the barrier-free SQP path, where μ has
    /// no meaning.
    pub final_mu: Number,

    // ---- Restoration-phase audit counters (pounce#12). ----
    //
    // Populated by `IpoptApplication::optimize_constrained` after a
    // solve completes. All three are 0 when restoration never fires.
    //
    /// Number of times `IpoptAlgorithm::invoke_restoration` was
    /// entered during this solve.
    /// Finite-difference Hessian census, when
    /// `hessian_approximation=finite-difference` actually built a pattern.
    /// All zero / `-1` on every other Hessian mode, which is how a caller
    /// tells "the mode did not run" from "it ran with an empty pattern".
    ///
    /// `fd_hessian_pattern_used` is the source the run **ended up with**,
    /// not the one requested: `0` declared, `1` jacobian, `-1` not run.
    /// `declared` silently falls back to `jacobian` when the TNLP declares
    /// no Hessian structure, and that fallback is the difference between
    /// 17 probe groups and 341 on `benchmarks/large_scale` `laptime`, so
    /// reporting the request would hide the number a reader is here for.
    pub fd_hessian_pattern_used: Index,
    /// Hessian nonzeros in the pattern that was coloured (lower triangle).
    pub fd_hessian_nnz: Index,
    /// Columns the colouring ran over, i.e. the problem's variable count.
    /// Present so the report is self-contained: `groups / n` is the
    /// compression, the fraction of a dense finite-difference scheme's
    /// probes this pattern costs, and without `n` a reader cannot form it.
    pub fd_hessian_n: Index,
    /// Probe groups per Hessian — the count of extra gradient/Jacobian
    /// evaluations each rebuild costs.
    pub fd_hessian_groups: Index,
    /// Widest row of the pattern; the quantity that decides whether the
    /// colouring can stay narrow under mesh refinement.
    pub fd_hessian_rho_max: Index,
    /// Whether a requested star colouring failed validation and CPR was
    /// substituted.
    pub fd_hessian_coloring_fell_back: bool,
    /// Whether the objective clique fell back to a conservative structural
    /// set because the model stated no objective linearity. This is the
    /// field that explains a surprising `fd_hessian_groups`: the clique is
    /// then `N`, or all `n`, and the probe count reflects that rather than
    /// the objective's true support.
    pub fd_hessian_objective_clique_widened: bool,
    pub restoration_calls: Index,
    /// Cumulative inner-IPM iterations across every restoration call —
    /// the number of `r`-suffix rows a `print_level=5` log would show.
    ///
    /// Each call contributes its sub-solve's own *length*: the inner
    /// counter is seeded from the outer's at entry (upstream
    /// `IpRestoMinC_1Nrm.cpp:181`), so the length is the terminating value
    /// minus the outer count at entry. Before gh #819 this summed the
    /// terminating values themselves — absolute positions in a shared
    /// numbering, not lengths — and recorded `0` for any call that failed,
    /// which is the case a reader is looking at this field to understand.
    pub restoration_inner_iters: Index,
    /// Number of *outer* iterations consumed by restoration: one per call,
    /// so this always equals `restoration_calls`.
    ///
    /// It is not the count of `r`-suffix rows — that is
    /// `restoration_inner_iters`, and reading this field as those rows is
    /// what the doc comment here said until gh #819. Restoration in POUNCE
    /// is a nested solve entered from a single outer iteration, not a mode
    /// the outer loop runs in, so there is no third number here to report.
    pub restoration_outer_iters: Index,
    /// Cumulative wall-clock seconds spent inside `perform_restoration`
    /// across all restoration calls. Useful for "what fraction of the
    /// solve was restoration?" without running with high print_level.
    pub restoration_wall_secs: Number,

    /// Successful linear-solver quality escalations over the whole solve
    /// — the main loop's and every restoration sub-solve's — i.e. the
    /// count of `q` flags in the info-string column (gh#857).
    ///
    /// An escalation is not an error and not, on its own, a problem: it
    /// is how the IPM answers a factorization that will not deliver.
    /// But with the FERAL backend it *reroutes the rest of the solve*,
    /// because that backend's ladder changes which pivots are taken and
    /// never steps back down, so a run that ends badly having escalated
    /// is a different animal from one that ends badly without. Before
    /// this counter the two were indistinguishable in a report, which is
    /// why gh#857's regression had to be found by instrumenting a build.
    ///
    /// `0` on the SQP and convex paths, which never escalate, and on any
    /// run whose backend declines to (`increase_quality` returning
    /// `false` is not counted — this counts escalations that *happened*,
    /// not escalations that were asked for).
    ///
    /// **On a laddered run this is the promoted solve's count, not the
    /// base solve's** — the same rule `iteration_count` follows, and the
    /// same trap. It is a sharp edge here because `feral_increase_quality_retry`
    /// promotes a re-solve that by construction escalated zero times, so
    /// a run whose base solve escalated twenty-five times reports `0`
    /// once the recovery lands. That is not a lost number: the ladder
    /// block records the base verdict alongside it, and
    /// `feral_increase_quality_retry=no` reproduces the base solve
    /// outright. The rung's own gate reads the base statistics inside the
    /// driver, before any promotion, so the gate is unaffected.
    pub quality_escalations: Index,

    /// gh#884. The solve observed the biactive dual-divergence signature:
    /// at one and the same iterate, a converged primal
    /// (`inf_pr <= dual_divergence_retry_primal_tol`), a scale-relative
    /// step at or below `dual_divergence_retry_step_tol`, and an
    /// *unscaled* dual infeasibility at or above
    /// `dual_divergence_retry_du_floor`.
    ///
    /// Reported whether or not a retry ran or promoted, so a caller can
    /// tell "the multipliers ran away on a settled iterate" from an exit
    /// that merely ran out of iterations.
    ///
    /// Unlike `quality_escalations` and `iteration_count`, which on a
    /// promoted run describe the promoted attempt alone, this accumulates
    /// across every attempt of one solve. That is deliberate: the reason
    /// a second solve happened at all is a fact about the *solve*, and a
    /// promoted run that reported `false` here would say the retry's
    /// answer came from nowhere.
    pub dual_divergence_signature: bool,
    /// gh#983 review item 3. The same detector, for the **one run whose point
    /// is returned** only. `dual_divergence_signature` accumulates across the
    /// attempts of a solve on purpose (above); a verdict about the returned
    /// point must not read a discarded sibling's flag, so the item-5 downgrade
    /// reads this one. Saved and restored with the rest of the run's tally
    /// when a retry is declined.
    #[cfg_attr(feature = "serde", serde(default))]
    pub returned_run_dual_divergence_signature: bool,
    /// gh#884. A dual-divergence retry ran *and* replaced the base
    /// attempt's answer. `false` both when no retry ran and when one ran
    /// and lost — in the latter case the returned point, status and
    /// residuals are the base attempt's.
    pub dual_divergence_retry_promoted: bool,

    // ---- Active-set SQP subproblem counters. ----
    //
    // Populated by `IpoptApplication::optimize_sqp_tnlp`; both stay 0
    // on the interior-point path, which has no QP subproblems.
    //
    /// Number of QP subproblems solved during this solve.
    pub sqp_qp_solves: Index,
    /// Active-set changes (adds + drops) summed over those QP
    /// subproblems. This is the measurement a working-set warm start
    /// is judged on: the outer iteration count can be identical
    /// between a cold and a warm solve while this differs by an order
    /// of magnitude, and on a QP-shaped NLP (one outer iteration by
    /// construction) it is the only thing that moves at all.
    pub sqp_qp_working_set_changes: Index,
    /// gh#986 review item 10: the SQP solve started from a caller-supplied
    /// working set (after mapping it through this problem's fixed-variable
    /// elimination). `false` on a cold solve, on the IPM path, and when a
    /// supplied working set had to be dropped -- which is also logged.
    #[cfg_attr(feature = "serde", serde(default))]
    pub sqp_warm_working_set_applied: bool,

    /// Per-iteration trajectory. Empty when the consumer doesn't ask
    /// for it (`iter_history_enabled = false` on the application or
    /// the binary's `--json-detail summary` mode). Populated in order
    /// by [`IpoptAlgorithm::iterate`] when enabled.
    pub iterations: Vec<IterRecord>,

    /// Per-pass summary of a multi-pass solve: one entry per ℓ₁
    /// exact-penalty ρ pass (preceded by the unwrapped attempt when the ℓ₁
    /// fallback produced them). Empty on an ordinary single-pass solve.
    ///
    /// When non-empty, `iteration_count` is the **total** over these passes
    /// and `iterations` is their rows concatenated (gh#987 item 6); every
    /// other `final_*` field describes the last pass, which produced the
    /// returned point.
    pub passes: Vec<SolvePassRecord>,
}

/// The eight residual fields default to **NaN, not zero**.
///
/// They are populated by the convergence check at the end of a solve. A solve
/// that never gets that far -- rejected during setup (`Not_Enough_Degrees_Of_Freedom`,
/// `Invalid_Problem_Definition`), aborted, or caught by the batch panic
/// handler -- leaves them untouched, and a default of `0.0` there reads as
/// "converged perfectly" rather than "never computed".
///
/// That is not hypothetical. `pounce.minimize` upgrades a non-success status
/// to `success=True` when the final KKT error is within the acceptable
/// tolerance, which is right for a solve that stalled near a good point. With
/// a zero default it also fired for problems the solver had *refused*: an
/// over-determined NLP returned `Not_Enough_Degrees_Of_Freedom` together with
/// `success=True` and an `x` outside its own variable bounds. NaN makes the
/// existing `is_finite` guard on that path do what its comment already claims.
///
/// Consequences worth knowing:
///
/// * NaN compares false against everything, so any `residual <= tol` test now
///   fails closed for an uncomputed value. That is the intent.
/// * `serde_json` renders non-finite floats as `null`, so these fields appear
///   as `null` rather than `0.0` in a solve report for an aborted solve. See
///   `docs/src/schema/solve-report-v1.md`.
///
/// The two objective fields are in the set for the same reason, though the
/// stakes are lower: nothing *decides* anything from them, they are only
/// reported (console summary, studio markdown, the JSON report). But `0.0` is
/// a perfectly ordinary objective value, so a reader cannot tell a solve that
/// legitimately reached zero from one that never evaluated anything. One rule
/// -- uncomputed is NaN -- is easier to reason about than "residuals are NaN,
/// objectives are zero, and you have to remember which is which". Note they
/// are seeded best-effort from the current iterate whenever one exists, so
/// they are only NaN when the solve died before producing any point at all.
///
/// `final_mu` is deliberately *not* in this set: `0.0` is its documented value
/// on the barrier-free SQP path, where mu has no meaning.
impl Default for SolveStatistics {
    fn default() -> Self {
        Self {
            iteration_count: 0,
            total_cpu_time_secs: 0.0,
            total_sys_time_secs: 0.0,
            total_wallclock_time_secs: 0.0,
            num_obj_evals: 0,
            num_constr_evals: 0,
            num_obj_grad_evals: 0,
            num_constr_jac_evals: 0,
            num_hess_evals: 0,
            final_objective: Number::NAN,
            final_scaled_objective: Number::NAN,
            final_dual_inf: Number::NAN,
            final_constr_viol: Number::NAN,
            final_compl: Number::NAN,
            final_kkt_error: Number::NAN,
            final_unscaled_dual_inf: Number::NAN,
            final_unscaled_dual_scale: Number::NAN,
            final_obj_scaling_factor: Number::NAN,
            final_total_obj_scaling_factor: Number::NAN,
            start_obj_grad_max: Number::NAN,
            warnings: Vec::new(),
            derivative_check: None,
            obj_scale_certificate_refused: false,
            obj_scale_acceptable_refused: false,
            final_declared_constr_viol: Number::NAN,
            final_declared_box_viol: Number::NAN,
            final_unscaled_constr_viol: Number::NAN,
            final_unscaled_compl: Number::NAN,
            final_unscaled_kkt_error: Number::NAN,
            final_kkt_error_above_noise: Number::NAN,
            final_mu: 0.0,
            // -1 = the finite-difference updater never built a pattern,
            // which is every run that is not `hessian_approximation=
            // finite-difference`. Distinct from 0, a real pattern source.
            fd_hessian_pattern_used: -1,
            fd_hessian_nnz: 0,
            fd_hessian_n: 0,
            fd_hessian_groups: 0,
            fd_hessian_rho_max: 0,
            fd_hessian_coloring_fell_back: false,
            fd_hessian_objective_clique_widened: false,
            restoration_calls: 0,
            restoration_inner_iters: 0,
            restoration_outer_iters: 0,
            restoration_wall_secs: 0.0,
            quality_escalations: 0,
            dual_divergence_signature: false,
            returned_run_dual_divergence_signature: false,
            dual_divergence_retry_promoted: false,
            sqp_qp_solves: 0,
            sqp_qp_working_set_changes: 0,
            sqp_warm_working_set_applied: false,
            iterations: Vec::new(),
            passes: Vec::new(),
        }
    }
}

impl SolveStatistics {
    pub fn new() -> Self {
        Self::default()
    }
}