Skip to main content

pounce_solve_report/
lib.rs

1//! Machine-readable JSON solve report (pounce#8).
2//!
3//! Bundles the same payload AMPL's `.sol` carries (status, primal,
4//! dual, suffixes) with FAIR-aligned provenance metadata (solver
5//! identity, input descriptor, timestamp) and per-iteration history
6//! when requested. Schema is versioned via the top-level `schema`
7//! field so future extensions don't silently change semantics.
8//!
9//! FAIR reference: Wilkinson et al. (2016). *The FAIR Guiding
10//! Principles for scientific data management and stewardship.*
11//! Scientific Data, 3, 160018. DOI:
12//! [10.1038/sdata.2016.18](https://doi.org/10.1038/sdata.2016.18).
13//! Verified via Crossref on 2026-05-14.
14//!
15//! # Schema versioning
16//!
17//! The current schema tag is `pounce.solve-report/v1`. Breaking
18//! changes bump the major version (v2 etc.). Adding fields without
19//! removing or renaming existing ones is non-breaking — JSON
20//! consumers should tolerate unknown fields.
21//!
22//! # Detail levels
23//!
24//! [`ReportDetail::Summary`] (default) emits the FAIR metadata,
25//! problem dimensions, final solution, and aggregate statistics
26//! — equivalent to a `.sol` plus provenance. [`ReportDetail::Full`]
27//! additionally emits the per-iteration history (when captured by
28//! [`pounce_algorithm::application::IpoptApplication::enable_iter_history`])
29//! and any `solution.suffixes`. Choose `Summary` for production logs
30//! and `Full` for debug captures.
31
32use std::path::{Path, PathBuf};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use pounce_common::types::{Index, Number};
36use pounce_linsol::summary::LinearSolverSummary;
37use pounce_nlp::return_codes::ApplicationReturnStatus;
38use pounce_nlp::solve_statistics::{IterRecord, SolveStatistics};
39use serde::{Deserialize, Serialize};
40
41/// Ipopt-style console printers (banner, problem statistics, end-of-run
42/// summary). The single source of truth for the text log: the algorithm's
43/// output layer emits these gated on `print_level`, and the CLI reuses the
44/// banner. Moved out of `pounce-cli` so `pounce-algorithm` can emit them
45/// natively (#206).
46pub mod console;
47
48/// Verbosity knob for the JSON report. Maps onto the `--json-detail`
49/// CLI flag.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ReportDetail {
52    /// FAIR metadata, problem, solution scalars + arrays, aggregate
53    /// stats. Per-iteration history and suffix blocks omitted.
54    Summary,
55    /// Everything in `Summary` plus per-iteration history and any
56    /// suffix outputs (`sens_sol_state_1`, reduced-Hessian blocks).
57    Full,
58}
59
60impl ReportDetail {
61    pub fn parse(s: &str) -> Result<Self, String> {
62        match s.to_ascii_lowercase().as_str() {
63            "summary" => Ok(ReportDetail::Summary),
64            "full" => Ok(ReportDetail::Full),
65            other => Err(format!(
66                "unknown --json-detail '{other}' (expected: summary | full)"
67            )),
68        }
69    }
70}
71
72/// Top-level report struct. Fields are ordered so the JSON has the
73/// most identifying / metadata fields first when pretty-printed.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SolveReport {
76    /// Schema identifier. Always
77    /// `"pounce.solve-report/v1"` for this version of the writer.
78    pub schema: String,
79    /// FAIR provenance metadata.
80    pub fair_metadata: FairMetadata,
81    /// Problem dimensions and shape.
82    pub problem: ProblemInfo,
83    /// Final solution payload (status, primal, dual, suffixes).
84    pub solution: SolutionInfo,
85    /// Aggregate statistics (eval counts, KKT residuals, timing).
86    pub statistics: StatisticsInfo,
87    /// Per-iteration history. Empty when the report is at
88    /// [`ReportDetail::Summary`] or iter history was never enabled.
89    #[serde(skip_serializing_if = "Vec::is_empty", default)]
90    pub iterations: Vec<IterRecord>,
91    /// Aggregate linear-solver post-mortem. Populated when the
92    /// workspace-default FERAL backend ran (it self-instruments via
93    /// `feral::Solver::last_factor_stats()`); `None` for HSL MA57 and
94    /// for custom backends plugged through
95    /// [`pounce_algorithm::application::IpoptApplication::set_linear_backend_factory`].
96    /// Additive — older `pounce.solve-report/v1` JSON without this
97    /// field deserializes unchanged.
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    pub linear_solver: Option<LinearSolverSummaryInfo>,
100    /// What the second-opinion ladder did, when it ran at all. `None` when the
101    /// verdict opened no ladder — which is every ordinary solve.
102    ///
103    /// Additive; older `pounce.solve-report/v1` JSON without this field
104    /// deserializes unchanged.
105    #[serde(skip_serializing_if = "Option::is_none", default)]
106    pub second_opinion: Option<SecondOpinionInfo>,
107    /// What the complementarity driver did, for a model with declared
108    /// complementarity pairs. `None` for every other model.
109    ///
110    /// Additive; older `pounce.solve-report/v1` JSON without this field
111    /// deserializes unchanged.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub complementarity: Option<ComplementarityInfo>,
114}
115
116/// The complementarity driver's record: which formulation answered, every
117/// lowered solve it ran, and the stationarity certificate of the reported
118/// point. Keyed like Python's `info["complementarity"]`.
119///
120/// `statistics.iteration_count` is the iteration count of the one lowered
121/// solve whose point was reported; the driver runs several, and
122/// [`Self::total_iterations`] is what the solve cost — the same honesty rule
123/// as [`SecondOpinionInfo::total_iteration_count`].
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ComplementarityInfo {
126    /// `complementarity_strategy`: `both`, `exact` or `continuation`.
127    pub strategy: String,
128    /// The formulation whose strongly stationary answer was reported, or
129    /// `None` when no formulation answered.
130    #[serde(skip_serializing_if = "Option::is_none", default)]
131    pub accepted_by: Option<String>,
132    /// The formulation whose weakly stationary point was reported when no
133    /// formulation answered, if that is what was reported.
134    #[serde(skip_serializing_if = "Option::is_none", default)]
135    pub fallback: Option<String>,
136    /// What was reported and on what grounds: `answer`, `weak_fallback`,
137    /// `feasible_uncertified` or `verdict`.
138    pub reported: String,
139    /// The driver's one-line account, as printed on the console.
140    pub summary: String,
141    /// Every lowered solve, in order.
142    pub solves: Vec<ComplementaritySolveInfo>,
143    /// Iterations summed over every lowered solve.
144    pub total_iterations: usize,
145    /// The reported point's complementarity violation -- the largest `|G·H|`
146    /// over one-sided pairs, the largest positive part of a mixed pair's
147    /// products -- or `null` when it has no point (a solve refused before
148    /// iteration 0).
149    pub max_abs_product: Option<f64>,
150    /// The stationarity certificate of the reported point; `None` only if the
151    /// model could not be evaluated there.
152    #[serde(skip_serializing_if = "Option::is_none", default)]
153    pub certificate: Option<ComplementarityCertificateInfo>,
154    /// The B-stationarity check of the reported point: run when it is
155    /// feasible and not certified S (S implies B). `None` when it did not
156    /// run. Additive; older reports without the field deserialize unchanged.
157    #[serde(skip_serializing_if = "Option::is_none", default)]
158    pub b_stationarity: Option<ComplementarityBStationarityInfo>,
159    /// Branch NLPs that moved the reported point off a non-B-stationary
160    /// one. Additive; reads `0` on reports written before the field existed.
161    #[serde(default)]
162    pub escapes: usize,
163    /// Pairs whose row was lifted into a variable (`complementarity_lift`):
164    /// their product rows were `G·w`, not `G·H(x)`. Additive; reads `0` on
165    /// reports written before the field existed.
166    #[serde(default)]
167    pub lifted: usize,
168    /// How complementary the reported point is: the largest `min(|G|, |H|)`
169    /// over its pairs, in the units of the pair sides. A point whose pairs
170    /// are complementary to `tol` reads near `tol`; one a relaxed stage left
171    /// at a nearly biactive pair reads near `sqrt(τ)` even when
172    /// `max_abs_product` reads `τ`. `null` when it could not be evaluated.
173    /// Additive; absent on reports written before the field existed.
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub natural_residual: Option<f64>,
176    /// The branch re-solve of a nearly biactive answer, when it ran: whether
177    /// its point was taken, the residual before and after, and why it was
178    /// not taken. Absent when it did not run (the answer's
179    /// `natural_residual` was within `10·tol`, or the point was not a
180    /// feasible accepted one). Additive.
181    #[serde(skip_serializing_if = "Option::is_none", default)]
182    pub polish: Option<ComplementarityPolishInfo>,
183    /// The `natural_residual` above which the driver runs the branch
184    /// re-solve (`10·tol`). A residual at or below it was left alone on
185    /// purpose. Additive; absent on reports written before the field existed.
186    #[serde(skip_serializing_if = "Option::is_none", default)]
187    pub polish_threshold: Option<f64>,
188}
189
190/// The branch re-solve of the complementarity driver's answer, keyed like
191/// Python's `info["complementarity"]["polish"]`.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ComplementarityPolishInfo {
194    /// The re-solved point replaced the answer.
195    pub taken: bool,
196    /// The answer's `natural_residual` before the re-solve.
197    pub residual_before: Option<f64>,
198    /// The re-solved point's; `null` if it was not measured.
199    pub residual_after: Option<f64>,
200    /// Why the re-solved point was not taken; `null` when it was.
201    pub reason: Option<String>,
202}
203
204/// The B-stationarity check of the complementarity driver's reported point,
205/// keyed like Python's `info["complementarity"]["b_stationarity"]`.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ComplementarityBStationarityInfo {
208    /// `B` (no branch descends), `not_B` (one does) or `not_checked`.
209    pub verdict: String,
210    /// Why the check was not decided; `null` unless `verdict` is
211    /// `not_checked`.
212    pub reason: Option<String>,
213    pub n_biactive: usize,
214    /// Branch LPs solved.
215    pub branches_solved: usize,
216    /// Whether every branch was enumerated (`false`: the heuristic used above
217    /// the enumeration limit, which can find a descent but never certify B).
218    pub enumerated: bool,
219}
220
221/// One lowered solve of the complementarity driver.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ComplementaritySolveInfo {
224    /// `exact product`, `Scholtes continuation` or `l1 penalty`.
225    pub stage: String,
226    /// The relaxation `τ` for a relaxed solve; absent for an exact one.
227    #[serde(skip_serializing_if = "Option::is_none", default)]
228    pub tau: Option<f64>,
229    /// Upstream status name, e.g. `Solve_Succeeded`.
230    pub status: String,
231    pub iterations: usize,
232    /// Complementarity violation at the solve's point (as
233    /// [`ComplementarityInfo::max_abs_product`]); `null` when it returned none.
234    pub max_abs_product: Option<f64>,
235    /// Wall-clock seconds of the solve. Additive; absent on reports written
236    /// before the field existed.
237    #[serde(skip_serializing_if = "Option::is_none", default)]
238    pub seconds: Option<f64>,
239}
240
241/// Which stationarity class the reported point satisfies.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ComplementarityCertificateInfo {
244    /// `S`, `W`, `none` or `not_searched`.
245    pub class: String,
246    pub n_pairs: usize,
247    pub n_biactive: usize,
248    /// Pairs with neither side at zero.
249    pub n_neither: usize,
250    /// Whether the stationarity multipliers are unique (MPCC-LICQ).
251    pub multipliers_unique: bool,
252    /// Stationarity residual at the multipliers that decided the class. `null`
253    /// when no multipliers were computed (an infeasible point).
254    pub residual: Option<f64>,
255    /// The band `residual` is judged against; `null` if non-finite (JSON has
256    /// no infinity, and a plain `f64` holding one would not read back).
257    pub resid_tol: Option<f64>,
258    /// Whether the residual is within the tight floor rather than only the
259    /// wider acceptance band.
260    pub tight: bool,
261    /// Largest violation of any row or bound, in the model's units; `null`
262    /// if it could not be evaluated.
263    pub primal_violation: Option<f64>,
264    /// `primal_violation <= constr_viol_tol`; an infeasible point classifies
265    /// `none` before any multiplier is computed.
266    pub feasible: bool,
267}
268
269/// The second-opinion ladder's record: which rungs ran, which (if any) was
270/// promoted, and **what the base solve did before any of it** (gh #850).
271///
272/// # Why the base solve's numbers are the point
273///
274/// On a promotion the reported `status` and `statistics.iteration_count` both
275/// become the promoted rung's, and nothing else in the report says the base
276/// solver failed. That makes a *lost* solve indistinguishable from a faster
277/// one, and it is worse than a gap in the evidence: it produces positive
278/// evidence for the wrong conclusion.
279///
280/// The case that exposed it is `square_flowsheet_resto`, where `v0.10.0`'s
281/// base solver converged in 116 iterations and HEAD's does not converge at all
282/// — `RestorationFailed` at 131 — with the answer coming from a ladder rung
283/// (`start_point_perturbation=1e-2`) added in the same release, which promotes
284/// at 54. `scripts/sweep-fixtures.sh` read that as `116 -> 54`, **a 2× win**.
285///
286/// The cost is understated on top of that: `statistics.iteration_count` is the
287/// promoted rung's alone, so the fixture's true cost is `131 + 54`, 3.4× what
288/// the report says. [`Self::total_iteration_count`] is the honest number.
289///
290/// This is the same shape of invisibility the sweep's engine column was added
291/// to close, and CLAUDE.md's rule applies verbatim: a line whose only moving
292/// field is "solved directly" → "promoted from a rung" is a trajectory change,
293/// and is as reportable as a moved iteration count.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct SecondOpinionInfo {
296    /// Rung labels actually run, in order.
297    pub tried: Vec<String>,
298    /// The rung whose re-solve was promoted, or `None` when the original
299    /// verdict survived every rung and shipped unchanged.
300    #[serde(skip_serializing_if = "Option::is_none", default)]
301    pub promoted_by: Option<String>,
302    /// The verdict the ladder was opened on, before any rung ran.
303    pub base_status: String,
304    /// Iterations the base solve spent. Not included in
305    /// `statistics.iteration_count` when a rung was promoted.
306    pub base_iteration_count: usize,
307    /// Iterations each rung in `tried` spent, in the same order.
308    pub rung_iteration_counts: Vec<usize>,
309    /// Base plus every rung: what the solve actually cost.
310    pub total_iteration_count: usize,
311}
312
313/// Serializable mirror of [`pounce_linsol::summary::LinearSolverSummary`].
314/// Lives in the CLI crate (rather than `pounce-linsol`) so the linsol
315/// trait crate stays serde-free. Field shape is identical; serde
316/// defaults keep it forward-compatible with future additions.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct LinearSolverSummaryInfo {
319    pub solver_name: String,
320    /// The `linear_solver` option as requested (gh#990 item 7); differs from
321    /// `solver_name` when the backend that ran is not the one asked for.
322    #[serde(skip_serializing_if = "Option::is_none", default)]
323    pub requested: Option<String>,
324    pub n_factors: u64,
325    pub n_pattern_reuse: u64,
326    pub n_pattern_changes: u64,
327    #[serde(skip_serializing_if = "Option::is_none", default)]
328    pub max_fill_ratio: Option<f64>,
329    #[serde(skip_serializing_if = "Option::is_none", default)]
330    pub min_abs_pivot: Option<f64>,
331    #[serde(skip_serializing_if = "Option::is_none", default)]
332    pub max_abs_pivot: Option<f64>,
333    /// `(positive, negative, zero)` inertia of the final factorisation.
334    #[serde(skip_serializing_if = "Option::is_none", default)]
335    pub last_inertia: Option<(usize, usize, usize)>,
336    /// `(positive, negative, zero)` inertia of the last factorization tried
337    /// with **no** regularization, i.e. the KKT matrix's own curvature
338    /// verdict (gh#987). `last_inertia` is the post-shift value.
339    #[serde(skip_serializing_if = "Option::is_none", default)]
340    pub last_inertia_unregularized: Option<(usize, usize, usize)>,
341    #[serde(skip_serializing_if = "Option::is_none", default)]
342    pub last_nnz_a: Option<usize>,
343    #[serde(skip_serializing_if = "Option::is_none", default)]
344    pub last_nnz_l: Option<usize>,
345
346    // Work and pivoting totals over every factorization. `default` keeps
347    // reports written before these fields existed readable.
348    #[serde(default)]
349    pub total_factor_secs: f64,
350    #[serde(default)]
351    pub total_factor_flops: f64,
352    #[serde(default)]
353    pub total_delayed_cols: u64,
354    #[serde(default)]
355    pub total_two_by_two: u64,
356    #[serde(default)]
357    pub total_n_tiny: u64,
358    #[serde(skip_serializing_if = "Option::is_none", default)]
359    pub last_factor_flops: Option<f64>,
360    #[serde(skip_serializing_if = "Option::is_none", default)]
361    pub last_peak_bytes: Option<usize>,
362    #[serde(skip_serializing_if = "Option::is_none", default)]
363    pub last_n_supernodes: Option<usize>,
364    #[serde(skip_serializing_if = "Option::is_none", default)]
365    pub last_max_front_rows: Option<usize>,
366    #[serde(skip_serializing_if = "Option::is_none", default)]
367    pub last_delayed_cols: Option<usize>,
368    #[serde(skip_serializing_if = "Option::is_none", default)]
369    pub last_two_by_two: Option<usize>,
370    #[serde(skip_serializing_if = "Option::is_none", default)]
371    pub last_n_tiny: Option<usize>,
372    #[serde(skip_serializing_if = "Option::is_none", default)]
373    pub last_ordering: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none", default)]
375    pub last_ordering_preprocess: Option<String>,
376    /// Present only when the Schur KKT path actually factored.
377    #[serde(skip_serializing_if = "Option::is_none", default)]
378    pub schur: Option<SchurSummaryInfo>,
379    /// Present only when the block-parallel KKT path actually factored.
380    #[serde(skip_serializing_if = "Option::is_none", default)]
381    pub blocks: Option<BlockSummaryInfo>,
382    /// The restoration phase's factorizations, same shape, kept apart from
383    /// the main-solve fields above. Present only when restoration factored.
384    #[serde(skip_serializing_if = "Option::is_none", default)]
385    pub restoration: Option<Box<LinearSolverSummaryInfo>>,
386}
387
388/// Serializable mirror of [`pounce_linsol::summary::BlockSummary`].
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct BlockSummaryInfo {
391    pub n_blocks: usize,
392    pub border_dim: usize,
393    pub largest_block: usize,
394    pub n_factors: u64,
395    pub factor_secs: f64,
396}
397
398/// Serializable mirror of [`pounce_linsol::summary::SchurSummary`].
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct SchurSummaryInfo {
401    pub n_eliminated: usize,
402    pub n_schur: usize,
403    pub n_factors: u64,
404    pub eliminated_factor_secs: f64,
405    pub form_schur_secs: f64,
406    pub schur_factor_secs: f64,
407}
408
409impl From<LinearSolverSummary> for LinearSolverSummaryInfo {
410    fn from(s: LinearSolverSummary) -> Self {
411        Self {
412            solver_name: s.solver_name,
413            requested: s.requested,
414            n_factors: s.n_factors,
415            n_pattern_reuse: s.n_pattern_reuse,
416            n_pattern_changes: s.n_pattern_changes,
417            max_fill_ratio: s.max_fill_ratio,
418            min_abs_pivot: s.min_abs_pivot,
419            max_abs_pivot: s.max_abs_pivot,
420            last_inertia: s.last_inertia,
421            last_inertia_unregularized: s.last_inertia_unregularized,
422            last_nnz_a: s.last_nnz_a,
423            last_nnz_l: s.last_nnz_l,
424            total_factor_secs: s.total_factor_secs,
425            total_factor_flops: s.total_factor_flops,
426            total_delayed_cols: s.total_delayed_cols,
427            total_two_by_two: s.total_two_by_two,
428            total_n_tiny: s.total_n_tiny,
429            last_factor_flops: s.last_factor_flops,
430            last_peak_bytes: s.last_peak_bytes,
431            last_n_supernodes: s.last_n_supernodes,
432            last_max_front_rows: s.last_max_front_rows,
433            last_delayed_cols: s.last_delayed_cols,
434            last_two_by_two: s.last_two_by_two,
435            last_n_tiny: s.last_n_tiny,
436            last_ordering: s.last_ordering,
437            last_ordering_preprocess: s.last_ordering_preprocess,
438            schur: s.schur.map(|c| SchurSummaryInfo {
439                n_eliminated: c.n_eliminated,
440                n_schur: c.n_schur,
441                n_factors: c.n_factors,
442                eliminated_factor_secs: c.eliminated_factor_secs,
443                form_schur_secs: c.form_schur_secs,
444                schur_factor_secs: c.schur_factor_secs,
445            }),
446            blocks: s.blocks.map(|b| BlockSummaryInfo {
447                n_blocks: b.n_blocks,
448                border_dim: b.border_dim,
449                largest_block: b.largest_block,
450                n_factors: b.n_factors,
451                factor_secs: b.factor_secs,
452            }),
453            restoration: s.restoration.map(|r| Box::new(Self::from(*r))),
454        }
455    }
456}
457
458/// FAIR-aligned provenance block. The four FAIR principles
459/// (Wilkinson et al., 2016) map onto fields here as:
460/// * **F**indable: `result_id` (unique per solve), `created_at_iso`.
461/// * **A**ccessible: this JSON file is the artifact — no protocol
462///   gating, plain text on disk.
463/// * **I**nteroperable: schema versioned, types are JSON primitives,
464///   units documented in field doc comments.
465/// * **R**eusable: `solver`, `license`, `input` describe what was
466///   solved with what code, enough to reproduce.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct FairMetadata {
469    /// Unique per-solve identifier. Composed as
470    /// `<unix_nanos>-<process_id>` so it is monotonically ordered
471    /// within a process and globally unique across processes.
472    pub result_id: String,
473    /// Solve start time as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SS.sssZ`).
474    pub created_at_iso: String,
475    /// Same instant in Unix nanoseconds (since 1970-01-01 UTC).
476    /// Provided alongside the ISO string for callers that prefer
477    /// integer arithmetic over date parsing.
478    pub created_at_unix_nanos: i128,
479    /// Wallclock seconds the solve took. Mirrors
480    /// [`SolveStatistics::total_wallclock_time_secs`].
481    pub elapsed_seconds: Number,
482    /// Solver identity — name + version + (best-effort) git commit.
483    pub solver: SolverIdentity,
484    /// SPDX license string. Always `"EPL-2.0"` for this crate.
485    pub license: String,
486    /// Input descriptor. `kind` is `nl-file`, `builtin`, or
487    /// `tnlp-direct` (for library callers).
488    pub input: InputDescriptor,
489    /// Solve-affecting environment variables present in the process
490    /// environment at report time (`POUNCE_FERAL_*` numerics knobs and
491    /// the legacy `FERAL_PIVTOL` / `FERAL_PARALLEL`). These alter the
492    /// factorization or parallelism and can otherwise silently differ a
493    /// run between two machines — e.g. one with `POUNCE_FERAL_PIVTOL`
494    /// exported in a shell profile — with nothing in the report saying
495    /// so (pounce#235). Recorded for reproducibility (the FAIR **R**
496    /// principle). Presence here means the variable was set, not
497    /// necessarily that it took effect: an explicit `OptionsList` setting
498    /// (e.g. a `feral_pivtol` in an options file) takes precedence over
499    /// the env fallback. Empty (and omitted from the JSON) when none are
500    /// set. Additive — older `pounce.solve-report/v1` JSON without this
501    /// field deserializes unchanged.
502    #[serde(skip_serializing_if = "Vec::is_empty", default)]
503    pub environment: Vec<EnvOverride>,
504}
505
506/// One solve-affecting environment variable and its value, as captured
507/// into [`FairMetadata::environment`].
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct EnvOverride {
510    /// Variable name, e.g. `POUNCE_FERAL_PIVTOL`.
511    pub name: String,
512    /// Its value verbatim, as read from the environment.
513    pub value: String,
514}
515
516/// Environment variables that change pounce's numerics or parallelism —
517/// the ones worth recording for reproducibility. Deliberately excludes the
518/// `POUNCE_DBG_*` debug gates (they only add diagnostic output, never
519/// altering the result) and compile-time / logging vars. Kept in a fixed
520/// order so [`capture_solve_env_overrides`] is deterministic.
521const SOLVE_AFFECTING_ENV_VARS: &[&str] = &[
522    "POUNCE_FERAL_ORDERING",
523    "POUNCE_FERAL_SCALING",
524    "POUNCE_FERAL_PIVTOL",
525    "POUNCE_FERAL_REFINE",
526    "POUNCE_FERAL_CASCADE_BREAK",
527    "POUNCE_FERAL_FMA",
528    "POUNCE_FERAL_SINGULAR_PIVOT_FLOOR",
529    "POUNCE_FERAL_MIN_PAR_FLOPS",
530    // Legacy aliases without the `POUNCE_` prefix (still honored by the
531    // FERAL backend): the deprecated pivot-threshold spelling and the
532    // process-wide internal-parallelism switch.
533    "FERAL_PIVTOL",
534    "FERAL_PARALLEL",
535];
536
537/// Snapshot the solve-affecting environment variables ([`SOLVE_AFFECTING_ENV_VARS`])
538/// that are currently set, for [`FairMetadata::environment`]. Reads the
539/// process environment, so call it in the solving process. Returns them in
540/// the fixed list order; absent variables are simply skipped.
541pub fn capture_solve_env_overrides() -> Vec<EnvOverride> {
542    SOLVE_AFFECTING_ENV_VARS
543        .iter()
544        .filter_map(|&name| {
545            std::env::var(name).ok().map(|value| EnvOverride {
546                name: name.to_string(),
547                value,
548            })
549        })
550        .collect()
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct SolverIdentity {
555    pub name: String,
556    pub version: String,
557    /// Git commit hash, captured at build time from the
558    /// `POUNCE_GIT_COMMIT` environment variable. `None` if the build
559    /// environment didn't set it — set via
560    /// `POUNCE_GIT_COMMIT=$(git rev-parse HEAD) cargo build`.
561    #[serde(skip_serializing_if = "Option::is_none")]
562    pub git_commit: Option<String>,
563    /// Build target triple (e.g. `x86_64-apple-darwin`). Captured at
564    /// build time from `TARGET` (Cargo standard env var).
565    pub target_triple: String,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(tag = "kind", rename_all = "kebab-case")]
570pub enum InputDescriptor {
571    NlFile {
572        path: PathBuf,
573        #[serde(skip_serializing_if = "Option::is_none")]
574        size_bytes: Option<u64>,
575    },
576    /// A Conic Benchmark Format (`.cbf`) instance — e.g. a CBLIB problem
577    /// solved through the convex conic driver.
578    CbfFile {
579        path: PathBuf,
580        #[serde(skip_serializing_if = "Option::is_none")]
581        size_bytes: Option<u64>,
582    },
583    Builtin {
584        name: String,
585    },
586    TnlpDirect,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ProblemInfo {
591    pub n_variables: Index,
592    pub n_constraints: Index,
593    pub n_objectives: Index,
594    pub minimize: bool,
595    /// Number of non-zeros declared by the TNLP for the constraint
596    /// Jacobian. `None` if not exposed by the input path.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub nnz_jac_g: Option<Index>,
599    /// Number of non-zeros declared for the Lagrangian Hessian.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub nnz_h_lag: Option<Index>,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
605pub struct SolutionInfo {
606    /// Which engine produced this verdict: `"cvx-qp"`, `"cvx-qcqp"`,
607    /// `"qp-active-set"`, `"sqp-active-set"`, `"square-lm"` or `"nlp"`.
608    ///
609    /// `"square-lm"` is the second-opinion ladder's square-system stage
610    /// (`pounce_algorithm::square_system`): the NLP arm ran and failed, and
611    /// the reported point came from Levenberg–Marquardt on `c(x) = 0`.
612    ///
613    /// `"qp-active-set"` and `"sqp-active-set"` are different engines and
614    /// not synonyms: the first is `pounce_convex::active_set` reached by
615    /// `solver_selection=qp-active-set` on an LP or convex QP, the second is
616    /// `pounce_algorithm::sqp`'s outer loop reached by
617    /// `algorithm=active-set-sqp` on a general NLP. Both solve their
618    /// subproblems with `pounce-qp`, which is why the names are close; they
619    /// wrap it in different algorithms, which is why they are not the same
620    /// string.
621    ///
622    /// The `Selected solver:` banner names the engine *routing* chose, which
623    /// is not always the one that answered: a convex solve that declines its
624    /// own result hands the model to the NLP arm (gh #535), and the banner
625    /// has already been printed by then. `scripts/sweep-fixtures.sh` scraped
626    /// that banner because the report carried nothing better, so a reroute
627    /// left no trace in the sweep diff — the precise blind spot CLAUDE.md
628    /// names when it says a routing regression "used to leave no trace".
629    ///
630    /// Empty when a path does not set it, which a consumer should read as
631    /// "unknown" rather than as any particular arm.
632    #[serde(default, skip_serializing_if = "String::is_empty")]
633    pub engine: String,
634    /// `SolveSucceeded`, `MaximumIterationsExceeded`, etc. The string
635    /// form is the Rust enum variant name verbatim.
636    pub status: ApplicationReturnStatus,
637    /// The same verdict in upstream Ipopt's C enumerator spelling —
638    /// `Solve_Succeeded`, `Infeasible_Problem_Detected` — from
639    /// `IpReturnCodes_inc.h`.
640    ///
641    /// [`Self::status`] carries the Rust variant name, which is *not* the
642    /// name any Ipopt-facing consumer already keys off: CUTEst status
643    /// tables, `benchmarks/scripts/run_nl_bench.sh`, the reference JSONs
644    /// under `benchmarks/*/ipopt_ma57.json` and the CLI's own `Status:`
645    /// line all spell it with separators. A consumer comparing
646    /// `solution.status == "Solve_Succeeded"` against the report matched
647    /// nothing and silently classified every solve as a failure (gh #767).
648    /// This field is that spelling, so the comparison can be literal.
649    ///
650    /// Derived from [`Self::status`] by [`ReportBuilder::finish`] — never
651    /// set by a caller, so the two cannot disagree. Empty when read back
652    /// from a pre-#767 report.
653    #[serde(default)]
654    pub status_upstream: String,
655    /// AMPL-style solve-result code (Gay 2005, §5 p. 23 table).
656    pub solve_result_num: i32,
657    /// Final unscaled objective value (mirrors
658    /// `SolveStatistics::final_objective`). `NaN` if unknown.
659    pub objective: Number,
660    /// Final primal vector, length `problem.n_variables`. Empty if
661    /// not captured.
662    #[serde(skip_serializing_if = "Vec::is_empty", default)]
663    pub x: Vec<Number>,
664    /// Final dual (constraint multiplier) vector, length
665    /// `problem.n_constraints`.
666    #[serde(skip_serializing_if = "Vec::is_empty", default)]
667    pub lambda: Vec<Number>,
668    /// Optional sIPOPT-style suffix blocks (`sens_sol_state_1` etc.).
669    /// Stored as a flat map keyed by suffix name → list of
670    /// `(index, value)` pairs, matching the AMPL `.sol` shape.
671    /// Empty when no sensitivity / reduced-Hessian step ran.
672    #[serde(skip_serializing_if = "Vec::is_empty", default)]
673    pub suffixes: Vec<SolutionSuffix>,
674}
675
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct SolutionSuffix {
678    pub name: String,
679    /// `"var" | "con" | "obj" | "problem"` per AMPL convention.
680    pub target: String,
681    /// `"int"` or `"real"`.
682    pub kind: String,
683    /// Dense values (length = target dimension); zero-filled for
684    /// slots the writer didn't populate. Real-typed values are stored
685    /// here; int-typed in `int_values`.
686    #[serde(skip_serializing_if = "Vec::is_empty", default)]
687    pub values: Vec<Number>,
688    #[serde(skip_serializing_if = "Vec::is_empty", default)]
689    pub int_values: Vec<Index>,
690}
691
692/// NaN, for a residual slot that was never filled in.
693fn uncomputed() -> Number {
694    Number::NAN
695}
696
697/// Accept `null` for a residual the solve never computed.
698///
699/// `SolveStatistics` defaults its residual fields to NaN rather than `0.0`, so
700/// that "the convergence check never ran" is distinguishable from "converged
701/// exactly". `serde_json` renders a non-finite float as `null`, so a report
702/// written for a solve that was refused during setup carries `null` in these
703/// slots. Without this the report round-trip fails — pounce would write
704/// reports its own `--cite` / studio / verify paths could not read back.
705fn null_as_nan<'de, D>(de: D) -> Result<Number, D::Error>
706where
707    D: serde::Deserializer<'de>,
708{
709    Ok(Option::<Number>::deserialize(de)?.unwrap_or_else(uncomputed))
710}
711
712/// One flagged derivative-checker comparison (gh#990 item 3).
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct DerivativeEntryInfo {
715    /// `gradient`, `jacobian`, `hessian`, `jacobian_structure` or
716    /// `hessian_structure`.
717    pub kind: String,
718    /// Hessian block (`obj` or `g[<row>]`); omitted otherwise.
719    #[serde(skip_serializing_if = "String::is_empty", default)]
720    pub block: String,
721    /// Row (`-1` for a gradient entry).
722    pub row: i64,
723    pub col: i64,
724    /// `None` for a structural miss (the entry is not in the declared pattern).
725    #[serde(skip_serializing_if = "Option::is_none", default)]
726    pub analytic: Option<f64>,
727    pub finite_difference: f64,
728    #[serde(skip_serializing_if = "Option::is_none", default)]
729    pub relative_error: Option<f64>,
730}
731
732/// The derivative checker's machine-readable verdict (gh#990 item 3); the
733/// console report on stderr is unchanged.
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct DerivativeCheckInfo {
736    /// `first-order`, `second-order` or `only-second-order`.
737    pub mode: String,
738    pub tolerance: f64,
739    pub perturbation: f64,
740    pub checked: usize,
741    pub suspicious: usize,
742    pub missing_structure: usize,
743    pub evaluations: usize,
744    /// Nothing flagged and something compared.
745    pub clean: bool,
746    #[serde(skip_serializing_if = "Option::is_none", default)]
747    pub max_rel_error_gradient: Option<f64>,
748    #[serde(skip_serializing_if = "Option::is_none", default)]
749    pub max_rel_error_jacobian: Option<f64>,
750    #[serde(skip_serializing_if = "Option::is_none", default)]
751    pub max_rel_error_hessian: Option<f64>,
752    /// The flagged entries (at most 200; `suspicious` is the exact count).
753    #[serde(default)]
754    pub flagged: Vec<DerivativeEntryInfo>,
755}
756
757/// The objective-scaling decision (gh#990 item 13).
758#[derive(Debug, Clone, Serialize, Deserialize)]
759pub struct ObjectiveScalingInfo {
760    /// The solver-computed objective scaling factor the run ended with
761    /// (excluding the caller's own `obj_scaling_factor`); `1` when none was
762    /// applied.
763    #[serde(skip_serializing_if = "Option::is_none", default)]
764    pub factor: Option<f64>,
765    /// gh#1001. `|obj_scaling_factor · factor · audit upscale|`: the whole
766    /// multiplier on the objective the returned run's IPM minimized, so
767    /// `statistics.final_scaled_objective` and each iteration's `mu` are in
768    /// units of `total_factor · f`. Differs from `factor` when the caller
769    /// set `obj_scaling_factor` or the gh#983 audit re-solved with the
770    /// objective scaled up.
771    #[serde(skip_serializing_if = "Option::is_none", default)]
772    pub total_factor: Option<f64>,
773    /// `max |grad f|` at the starting point, as gradient-based scaling
774    /// measured it.
775    #[serde(skip_serializing_if = "Option::is_none", default)]
776    pub start_gradient_max: Option<f64>,
777    /// A strict termination certificate was refused because the objective
778    /// scaling masked it (gh#200), and the solve kept iterating.
779    #[serde(default)]
780    pub certificate_refused: bool,
781    /// Same, for an acceptable-level certificate.
782    #[serde(default)]
783    pub acceptable_certificate_refused: bool,
784}
785
786impl From<&pounce_nlp::derivative_test::DerivativeCheckSummary> for DerivativeCheckInfo {
787    fn from(c: &pounce_nlp::derivative_test::DerivativeCheckSummary) -> Self {
788        let fin = |v: Option<f64>| v.filter(|x| x.is_finite());
789        Self {
790            mode: c.mode.clone(),
791            tolerance: c.tolerance,
792            perturbation: c.perturbation,
793            checked: c.checked,
794            suspicious: c.suspicious,
795            missing_structure: c.missing_structure,
796            evaluations: c.evaluations,
797            clean: c.clean,
798            max_rel_error_gradient: fin(c.max_rel_error_gradient),
799            max_rel_error_jacobian: fin(c.max_rel_error_jacobian),
800            max_rel_error_hessian: fin(c.max_rel_error_hessian),
801            flagged: c
802                .flagged
803                .iter()
804                .map(|e| DerivativeEntryInfo {
805                    kind: e.kind.to_string(),
806                    block: e.block.clone(),
807                    row: e.row,
808                    col: e.col,
809                    analytic: fin(e.analytic),
810                    finite_difference: if e.finite_difference.is_finite() {
811                        e.finite_difference
812                    } else {
813                        0.0
814                    },
815                    relative_error: fin(e.relative_error),
816                })
817                .collect(),
818        }
819    }
820}
821
822/// Subset of `SolveStatistics` projected for the report. Mirrors the
823/// fields the existing console summary prints.
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct StatisticsInfo {
826    pub iteration_count: Index,
827    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
828    pub final_objective: Number,
829    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
830    pub final_scaled_objective: Number,
831    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
832    pub final_dual_inf: Number,
833    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
834    pub final_constr_viol: Number,
835    /// Primal violation against the model **as declared**, before the convex
836    /// arm's `bound_relax_factor` widening (`qp_extract::BoundRelax`, gh
837    /// #744/#745).
838    ///
839    /// `final_constr_viol` measures the model the solver was HANDED, whose
840    /// inequality rows and variable box are widened by `min(factor,cap)·|b|`.
841    /// That is the model its convergence test is about and every acceptance
842    /// gate reads — and it is not how far the returned point sits outside the
843    /// model the caller wrote. On netlib `afiro` the point is `4.99e-06`
844    /// outside a declared row `b = 500` (exactly `1e-8·500`) while
845    /// `final_constr_viol` reads `8.68e-13`; `25fv47` reports `2.19e-11`
846    /// against `1.97e-05`.
847    ///
848    /// Reported on every solve of the NLP and convex arms, widened or not
849    /// (gh#987: it is no longer `NaN` as a "no widening" signal); under
850    /// `l1_exact_penalty_barrier` it is the caller's own rows, not the
851    /// wrapper's augmented ones. `NaN` only on a path that does not compute
852    /// it. Additive to `pounce.solve-report/v1`: readers predating it are
853    /// unaffected.
854    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
855    pub final_declared_constr_viol: Number,
856    /// How far the returned point sits outside the **declared** variable box —
857    /// the box the caller wrote, before the `bound_relax_factor` widening.
858    /// Ipopt's `Variable bound violation`, and the box half of
859    /// `final_declared_constr_viol` reported on its own: maxed together, a box
860    /// violation and a row violation cannot be told apart.
861    ///
862    /// Variable bounds carry no scaling, so there is one number rather than a
863    /// scaled/unscaled pair. `NaN` on a path that does not compute it.
864    /// Additive to `pounce.solve-report/v1`: readers predating it are
865    /// unaffected.
866    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
867    pub final_declared_box_viol: Number,
868    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
869    pub final_compl: Number,
870    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
871    pub final_kkt_error: Number,
872    /// The aggregate the strict convergence gate tested (gh #528): as
873    /// `final_kkt_error`, but counting each constraint row's residual only
874    /// where it exceeds what that row can represent in floating point. Equal
875    /// to `final_kkt_error` unless a row is at its own resolution limit.
876    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
877    pub final_kkt_error_above_noise: Number,
878    pub num_obj_evals: Index,
879    pub num_constr_evals: Index,
880    pub num_obj_grad_evals: Index,
881    pub num_constr_jac_evals: Index,
882    pub num_hess_evals: Index,
883    pub total_wallclock_time_secs: Number,
884    pub restoration_calls: Index,
885    pub restoration_inner_iters: Index,
886    pub restoration_outer_iters: Index,
887    pub restoration_wall_secs: Number,
888    /// Successful linear-solver quality escalations over the whole solve,
889    /// restoration sub-solves included (gh#857). `0` on paths that never
890    /// escalate. `serde(default)` so a report written before this field
891    /// existed still deserializes — it reads as "no escalations", which
892    /// is wrong-but-harmless for an old file and correct for every new
893    /// one.
894    #[serde(default)]
895    pub quality_escalations: Index,
896    /// The solve saw the gh#884 biactive dual-divergence signature: at one
897    /// and the same iterate a converged primal, a scale-relative step at
898    /// zero, and an *unscaled* dual infeasibility far above `dual_inf_tol`.
899    /// Reported whether or not a retry ran or promoted, because the
900    /// distinction it draws — the multipliers ran away on a settled
901    /// iterate, as against the iterate itself never settling — is not
902    /// visible in any other field. `serde(default)` for the same reason as
903    /// `quality_escalations`.
904    #[serde(default)]
905    pub dual_divergence_signature: bool,
906    /// A gh#884 dual-divergence retry ran *and* its answer was returned.
907    /// `false` both when no retry ran and when one ran and lost — in the
908    /// latter case every other field here describes the base attempt.
909    #[serde(default)]
910    pub dual_divergence_retry_promoted: bool,
911    /// Every attempt's iterations when the solve re-solved itself (a gh#983
912    /// scale re-solve, a gh#884 dual-divergence retry, or both, each with any
913    /// `mu_strategy_fallback` re-solve inside it), absent otherwise.
914    /// `iteration_count` above is one attempt's either way. Additive; absent
915    /// on reports written before it.
916    #[serde(default, skip_serializing_if = "Option::is_none")]
917    pub retry_total_iterations: Option<i64>,
918    /// gh#1042 C1. What the `mu_strategy_fallback` retry did, when it ran:
919    /// the first attempt's status, iterations, restoration calls and
920    /// objective, the re-solve's status and iterations, and whether the
921    /// re-solve was promoted. On a promotion `iteration_count` and
922    /// `restoration_calls` above are the **re-solve's alone**, so this is the
923    /// only place the first attempt is visible — the gh#850 argument for
924    /// `second_opinion`, one mechanism over. Omitted when no retry ran;
925    /// additive to `pounce.solve-report/v1`.
926    #[serde(skip_serializing_if = "Option::is_none", default)]
927    pub mu_strategy_fallback: Option<pounce_nlp::MuStrategyFallbackRecord>,
928    /// gh#983. Structured solve-quality warnings about the returned point,
929    /// each `"<code>: <text>"` (`objective_scale_small`,
930    /// `unscaled_stationarity_above_tol`, `large_dual_scale`,
931    /// `unscaled_dual_inf_above_acceptable`). Empty on a clean run; never
932    /// changes the status. `serde(default)` so older reports still load.
933    #[serde(default)]
934    pub warnings: Vec<String>,
935    /// gh#990 item 3. The derivative checker's machine-readable verdict;
936    /// present only when `derivative_test` ran for this solve.
937    #[serde(skip_serializing_if = "Option::is_none", default)]
938    pub derivative_check: Option<DerivativeCheckInfo>,
939    /// gh#990 item 13. The objective-scaling decision: the factor
940    /// gradient-based scaling chose, the gradient scale it measured, and
941    /// whether a termination certificate was refused because the scaling
942    /// masked it. Absent when none of that is known (a path that does not
943    /// scale the objective).
944    #[serde(skip_serializing_if = "Option::is_none", default)]
945    pub objective_scaling: Option<ObjectiveScalingInfo>,
946    /// Per-pass summary of a multi-pass solve (gh#987 item 6): one entry per
947    /// ℓ₁ exact-penalty ρ pass, preceded by the unwrapped attempt when the ℓ₁
948    /// fallback ran. Omitted on an ordinary single-pass solve. When present,
949    /// `iteration_count` is the total across the passes and `iterations`
950    /// (at full detail) their concatenated rows; `first_row` in each entry
951    /// marks where a pass's rows start. Additive to `pounce.solve-report/v1`.
952    #[serde(default, skip_serializing_if = "Vec::is_empty")]
953    pub passes: Vec<pounce_nlp::SolvePassRecord>,
954}
955
956/// Builder collecting the inputs for a [`SolveReport`]. The CLI
957/// drivers populate one of these as they walk through the solve and
958/// `finish()` it at the end.
959pub struct ReportBuilder {
960    detail: ReportDetail,
961    started_at: SystemTime,
962    started_unix_nanos: i128,
963    pub input: InputDescriptor,
964    pub problem: ProblemInfo,
965    pub solution: SolutionInfo,
966    pub stats: StatisticsInfo,
967    pub iterations: Vec<IterRecord>,
968    pub linear_solver: Option<LinearSolverSummaryInfo>,
969    pub second_opinion: Option<SecondOpinionInfo>,
970    pub complementarity: Option<ComplementarityInfo>,
971}
972
973impl ReportBuilder {
974    pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
975        let now = SystemTime::now();
976        let nanos = now
977            .duration_since(UNIX_EPOCH)
978            .map(|d| d.as_nanos() as i128)
979            .unwrap_or(0);
980        Self {
981            detail,
982            started_at: now,
983            started_unix_nanos: nanos,
984            input,
985            problem: ProblemInfo {
986                n_variables: 0,
987                n_constraints: 0,
988                n_objectives: 0,
989                minimize: true,
990                nnz_jac_g: None,
991                nnz_h_lag: None,
992            },
993            solution: SolutionInfo {
994                engine: String::new(),
995                status: ApplicationReturnStatus::InternalError,
996                // Overwritten from `status` by `finish`; see the field docs.
997                status_upstream: String::new(),
998                solve_result_num: 500,
999                // 0.0 (not NaN) so JSON round-trips. Callers that
1000                // need "unknown objective" semantics check
1001                // `statistics.iteration_count > 0` first.
1002                objective: 0.0,
1003                x: Vec::new(),
1004                lambda: Vec::new(),
1005                suffixes: Vec::new(),
1006            },
1007            stats: empty_stats(),
1008            iterations: Vec::new(),
1009            linear_solver: None,
1010            second_opinion: None,
1011            complementarity: None,
1012        }
1013    }
1014
1015    /// Record what the complementarity driver did. Called only for a model
1016    /// with declared complementarity pairs.
1017    pub fn set_complementarity(&mut self, info: ComplementarityInfo) {
1018        self.complementarity = Some(info);
1019    }
1020
1021    /// Record what the second-opinion ladder did. Called only when it ran; a
1022    /// verdict that opens no ladder leaves this `None` and the field out of
1023    /// the JSON entirely (gh #850).
1024    pub fn set_second_opinion(&mut self, info: SecondOpinionInfo) {
1025        self.second_opinion = Some(info);
1026    }
1027
1028    /// Attach a linear-solver post-mortem. Called once per solve after
1029    /// `optimize_tnlp` returns and before [`Self::finish`].
1030    pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
1031        self.linear_solver = Some(summary.into());
1032    }
1033
1034    /// Pull `iteration_count`, `final_*`, and counters into the
1035    /// `stats` slot; copy `iterations` only if detail = Full.
1036    pub fn ingest_stats(&mut self, src: &SolveStatistics) {
1037        self.stats = StatisticsInfo {
1038            iteration_count: src.iteration_count,
1039            final_objective: src.final_objective,
1040            final_scaled_objective: src.final_scaled_objective,
1041            final_dual_inf: src.final_dual_inf,
1042            final_constr_viol: src.final_constr_viol,
1043            final_declared_constr_viol: src.final_declared_constr_viol,
1044            final_declared_box_viol: src.final_declared_box_viol,
1045            final_compl: src.final_compl,
1046            final_kkt_error: src.final_kkt_error,
1047            final_kkt_error_above_noise: src.final_kkt_error_above_noise,
1048            num_obj_evals: src.num_obj_evals,
1049            num_constr_evals: src.num_constr_evals,
1050            num_obj_grad_evals: src.num_obj_grad_evals,
1051            num_constr_jac_evals: src.num_constr_jac_evals,
1052            num_hess_evals: src.num_hess_evals,
1053            total_wallclock_time_secs: src.total_wallclock_time_secs,
1054            restoration_calls: src.restoration_calls,
1055            restoration_inner_iters: src.restoration_inner_iters,
1056            restoration_outer_iters: src.restoration_outer_iters,
1057            restoration_wall_secs: src.restoration_wall_secs,
1058            quality_escalations: src.quality_escalations,
1059            dual_divergence_signature: src.dual_divergence_signature,
1060            dual_divergence_retry_promoted: src.dual_divergence_retry_promoted,
1061            retry_total_iterations: src.retry_total_iterations.map(i64::from),
1062            mu_strategy_fallback: src.mu_strategy_fallback.clone(),
1063            warnings: src.warnings.clone(),
1064            derivative_check: src.derivative_check.as_ref().map(DerivativeCheckInfo::from),
1065            objective_scaling: {
1066                let fin = |v: f64| v.is_finite().then_some(v);
1067                let (factor, total, start) = (
1068                    fin(src.final_obj_scaling_factor),
1069                    fin(src.final_total_obj_scaling_factor),
1070                    fin(src.start_obj_grad_max),
1071                );
1072                (factor.is_some()
1073                    || total.is_some()
1074                    || start.is_some()
1075                    || src.obj_scale_certificate_refused
1076                    || src.obj_scale_acceptable_refused)
1077                    .then_some(ObjectiveScalingInfo {
1078                        factor,
1079                        total_factor: total,
1080                        start_gradient_max: start,
1081                        certificate_refused: src.obj_scale_certificate_refused,
1082                        acceptable_certificate_refused: src.obj_scale_acceptable_refused,
1083                    })
1084            },
1085            passes: src.passes.clone(),
1086        };
1087        if matches!(self.detail, ReportDetail::Full) {
1088            self.iterations = src.iterations.clone();
1089        }
1090    }
1091
1092    pub fn finish(self) -> SolveReport {
1093        let elapsed = self
1094            .started_at
1095            .elapsed()
1096            .map(|d| d.as_secs_f64())
1097            .unwrap_or(0.0);
1098        // Derived here rather than at each call site: every producer of a
1099        // report (CLI, C interface, Python bindings, the CBF driver) sets
1100        // `solution.status` and none of them can forget the upstream
1101        // spelling, nor set one that disagrees with the other (gh #767).
1102        let mut solution = self.solution;
1103        solution.status_upstream = solution.status.upstream_name().to_string();
1104        // WASI has no process id (`std::process::id` aborts there); in the
1105        // browser one page runs one solve at a time, so the timestamp alone
1106        // identifies it.
1107        #[cfg(not(target_family = "wasm"))]
1108        let pid = std::process::id();
1109        #[cfg(target_family = "wasm")]
1110        let pid = 0;
1111        let result_id = format!("{}-{pid}", self.started_unix_nanos);
1112        let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
1113
1114        SolveReport {
1115            schema: "pounce.solve-report/v1".to_string(),
1116            fair_metadata: FairMetadata {
1117                result_id,
1118                created_at_iso,
1119                created_at_unix_nanos: self.started_unix_nanos,
1120                elapsed_seconds: elapsed,
1121                solver: SolverIdentity {
1122                    name: "pounce".to_string(),
1123                    version: env!("CARGO_PKG_VERSION").to_string(),
1124                    git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
1125                    target_triple: TARGET_TRIPLE.to_string(),
1126                },
1127                license: "EPL-2.0".to_string(),
1128                input: self.input,
1129                environment: capture_solve_env_overrides(),
1130            },
1131            problem: self.problem,
1132            solution,
1133            statistics: self.stats,
1134            iterations: self.iterations,
1135            linear_solver: self.linear_solver,
1136            second_opinion: self.second_opinion,
1137            complementarity: self.complementarity,
1138        }
1139    }
1140}
1141
1142/// The build target triple (e.g. `aarch64-apple-darwin`).
1143///
1144/// Cargo only exposes `TARGET` to *build scripts*, not to crate source, so
1145/// `option_env!("TARGET")` here is always `None`. Our `build.rs` re-exports
1146/// the build script's `TARGET` as `POUNCE_TARGET_TRIPLE`, which we read
1147/// instead. Falls back to "unknown" if the build script did not run (e.g.
1148/// some non-Cargo tooling).
1149const TARGET_TRIPLE: &str = match option_env!("POUNCE_TARGET_TRIPLE") {
1150    Some(t) => t,
1151    None => "unknown",
1152};
1153
1154fn empty_stats() -> StatisticsInfo {
1155    // All scalar fields start at 0.0 (not NaN) so the report
1156    // round-trips through `serde_json` — JSON has no NaN literal, and
1157    // serde_json's default is to write `null` for NaN, which then
1158    // fails to deserialize back into `Number`. Callers reading these
1159    // pre-solve treat `iteration_count == 0` as "no solve yet".
1160    StatisticsInfo {
1161        iteration_count: 0,
1162        final_objective: 0.0,
1163        final_scaled_objective: 0.0,
1164        final_dual_inf: 0.0,
1165        final_constr_viol: 0.0,
1166        // not "uncomputed": this is the pre-solve placeholder, and 0.0 is
1167        // what every residual beside it carries here.
1168        final_declared_constr_viol: 0.0,
1169        final_declared_box_viol: 0.0,
1170        final_compl: 0.0,
1171        final_kkt_error: 0.0,
1172        final_kkt_error_above_noise: 0.0,
1173        num_obj_evals: 0,
1174        num_constr_evals: 0,
1175        num_obj_grad_evals: 0,
1176        num_constr_jac_evals: 0,
1177        num_hess_evals: 0,
1178        total_wallclock_time_secs: 0.0,
1179        restoration_calls: 0,
1180        restoration_inner_iters: 0,
1181        restoration_outer_iters: 0,
1182        restoration_wall_secs: 0.0,
1183        quality_escalations: 0,
1184        dual_divergence_signature: false,
1185        dual_divergence_retry_promoted: false,
1186        retry_total_iterations: None,
1187        mu_strategy_fallback: None,
1188        warnings: Vec::new(),
1189        derivative_check: None,
1190        objective_scaling: None,
1191        passes: Vec::new(),
1192    }
1193}
1194
1195/// AMPL-style `solve_result_num` per Gay 2005 (Hooking Your Solver to
1196/// AMPL §5, p. 23 table): 0 = solved, 100s = warning, 200s =
1197/// infeasible, 300s = unbounded, 400s = limit reached, 500s = failure.
1198/// Shared by the CLI and cinterface report writers so both encode the
1199/// same int codes into `SolutionInfo::solve_result_num`.
1200///
1201/// `DivergingIterates` is Ipopt's unboundedness signal (the iterates run
1202/// off to infinity), so it maps to the 300 "unbounded" range — matching
1203/// upstream Ipopt's ASL driver and the CLI's own convex path, which
1204/// reports `QpStatus::DualInfeasible` (unbounded) as 300 (`main.rs`). It
1205/// is *not* a limit (400) condition.
1206///
1207/// `SolvedToAcceptableLevel` is `1`, not the 100 band, matching Ipopt's
1208/// ASL driver exactly (`Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`:
1209/// `STOP_AT_ACCEPTABLE_POINT` → `solve_result_num = 1`, message
1210/// "Solved To Acceptable Level."). The band is what consumers key on, and
1211/// the two bands are not interchangeable here: Pyomo's legacy `.sol`
1212/// reader turns `0..=99` into `status=ok` but `100..=199` into
1213/// `status=warning` with the same `termination_condition=optimal`, so the
1214/// 100 band made Pyomo log a "Loading a SolverResults object with a
1215/// warning status" warning on an accepted solve that Ipopt loads clean —
1216/// breaking solver-swappable clients whose accepted-solve contract
1217/// includes `status == ok` (gh #591). The reduced-accuracy convergence
1218/// stays visible in the status name and the `.sol` message line; it just
1219/// no longer reads as a warning.
1220///
1221/// `FeasiblePointFound` is `2`, Ipopt's own code, and therefore in the
1222/// `0..=99` solved band. It used to be `100`, justified by the claim that
1223/// the two statuses do not mean the same thing — that Ipopt returns
1224/// `FEASIBLE_POINT_FOUND` only for a square problem, where a feasible
1225/// point *is* the solution, while POUNCE used it more loosely for any
1226/// usable feasible point that missed the convergence criteria.
1227///
1228/// That claim was false about POUNCE's own code. The status has exactly
1229/// one production site: `min_c_1nrm.rs` returns
1230/// `RestorationOutcome::FeasiblePointFound`, reached only through the
1231/// gate at `resto_inner_solver.rs`, which is `is_square_problem && ...`.
1232/// `is_square_problem()` (`ipopt_alg.rs`) is `c.x.dim() == c.y_c.dim()`,
1233/// a port of `IpoptCalculatedQuantities::IsSquareProblem` — the same
1234/// condition Ipopt uses. So POUNCE emits this status *only* for square
1235/// problems, carrying Ipopt's meaning precisely, and on a square problem
1236/// there is no further convergence criterion to miss: the objective is
1237/// constant, so a feasible point is the solution.
1238///
1239/// The band is what consumers key on, and `100` was not a softer way of
1240/// saying the same thing — it inverted the answer. Pyomo's v2 reader
1241/// (`pyomo/contrib/solver/solvers/asl_sol_reader.py`) maps `100..=199` to
1242/// `TerminationCondition.error`, so a correct square-problem solve
1243/// reached the caller as a *solver error*; the legacy reader
1244/// (`pyomo/opt/plugins/sol.py`) maps it to `optimal` + `status=warning`,
1245/// the same gh #591 warning `SolvedToAcceptableLevel` was moved out of the
1246/// band to escape. Ipopt on the identical solve loads clean in both. This
1247/// is what gh #815 surfaced: an IDAES square flowsheet that POUNCE solves
1248/// to a constraint violation of 2.2e-06 was reported as a failure.
1249///
1250/// This crate is not the only place that had to agree. `python/pounce/gams/link.py`
1251/// already mapped the status to `(MODELSTAT_FEASIBLE, SOLVESTAT_NORMAL)`
1252/// and listed it as a success, and
1253/// `crates/pounce-algorithm/tests/issue_390_nonlinear_equality_scale.rs`
1254/// already called it "a success-band answer — AMPL `objno` code 2, which
1255/// every band table reads as SOLVED". The two Pyomo tables
1256/// (`pyomo_pounce.v2._V2_STATUS`, `pyomo_pounce.sens._STATUS_RESULT`) were
1257/// the dissenters and moved with this change.
1258///
1259/// Being in the solved band is *not* a claim that any feasible point is
1260/// acceptable. `issue_390_nonlinear_equality_scale.rs` is the guard that a
1261/// model with no solution is never reported feasible at any row scale; the
1262/// relative-violation threshold at `resto_inner_solver.rs` is what keeps
1263/// that true. This mapping decides how a verdict is reported, not when it
1264/// is reached.
1265pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
1266    use ApplicationReturnStatus::*;
1267    match status {
1268        SolveSucceeded => 0,
1269        SolvedToAcceptableLevel => 1,
1270        FeasiblePointFound => 2,
1271        InfeasibleProblemDetected => 200,
1272        DivergingIterates => 300,
1273        SearchDirectionBecomesTooSmall => 400,
1274        MaximumIterationsExceeded => 400,
1275        MaximumCpuTimeExceeded => 400,
1276        MaximumWallTimeExceeded => 400,
1277        UserRequestedStop => 502,
1278        RestorationFailed => 500,
1279        ErrorInStepComputation => 500,
1280        InvalidNumberDetected => 500,
1281        InternalError => 500,
1282        UnrecoverableException => 500,
1283        NonIpoptExceptionThrown => 500,
1284        InsufficientMemory => 503,
1285        InvalidProblemDefinition => 504,
1286        InvalidOption => 504,
1287        NotEnoughDegreesOfFreedom => 504,
1288    }
1289}
1290
1291/// Write a [`SolveReport`] to `path` as pretty-printed JSON. Returns
1292/// bytes written on success.
1293pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
1294    let s = serde_json::to_string_pretty(report)
1295        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
1296    std::fs::write(path, &s)?;
1297    Ok(s.len())
1298}
1299
1300/// Convert Unix nanoseconds since the epoch to an ISO-8601 UTC
1301/// timestamp `YYYY-MM-DDTHH:MM:SS.sssZ`. Pure stdlib; no chrono /
1302/// time dependency. The conversion is based on the proleptic
1303/// Gregorian calendar formula from Howard Hinnant's "date" reference
1304/// (https://howardhinnant.github.io/date_algorithms.html), `days_from_civil`
1305/// in reverse — verified against `date -u -r <secs>` for several
1306/// epochs on 2026-05-14.
1307fn unix_nanos_to_iso(nanos: i128) -> String {
1308    let total_secs = nanos.div_euclid(1_000_000_000) as i64;
1309    let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
1310    let millis = frac_nanos / 1_000_000;
1311
1312    let days = total_secs.div_euclid(86_400);
1313    let secs_of_day = total_secs.rem_euclid(86_400);
1314    let hh = (secs_of_day / 3600) as i32;
1315    let mm = ((secs_of_day % 3600) / 60) as i32;
1316    let ss = (secs_of_day % 60) as i32;
1317
1318    // Howard Hinnant's `civil_from_days` algorithm:
1319    //   z = days + 719468
1320    //   era = (z >= 0 ? z : z - 146096) / 146097
1321    //   doe = z - era*146097
1322    //   yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365
1323    //   y = yoe + era*400
1324    //   doy = doe - (365*yoe + yoe/4 - yoe/100)
1325    //   mp = (5*doy + 2) / 153
1326    //   d = doy - (153*mp + 2)/5 + 1
1327    //   m = mp < 10 ? mp + 3 : mp - 9
1328    //   y += (m <= 2)
1329    let z: i64 = days + 719468;
1330    let era = if z >= 0 { z } else { z - 146096 } / 146097;
1331    let doe = (z - era * 146097) as i64; // [0, 146096]
1332    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
1333    let mut y = yoe + era * 400;
1334    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
1335    let mp = (5 * doy + 2) / 153; // [0, 11]
1336    let d = (doy - (153 * mp + 2) / 5 + 1) as i32;
1337    let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
1338    if m <= 2 {
1339        y += 1;
1340    }
1341
1342    format!(
1343        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
1344        y, m, d, hh, mm, ss, millis
1345    )
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    /// A complementarity block round-trips, including the quantities that are
1353    /// `null` because they were not measured: JSON has no infinity, and a
1354    /// plain `f64` field holding one would serialize as `null` and then fail
1355    /// to read back (the CLI re-reads its own reports).
1356    #[test]
1357    fn complementarity_block_round_trips_with_unmeasured_values() {
1358        let mut b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect);
1359        b.solution.status = ApplicationReturnStatus::InfeasibleProblemDetected;
1360        b.set_complementarity(ComplementarityInfo {
1361            strategy: "both".into(),
1362            accepted_by: None,
1363            fallback: None,
1364            reported: "verdict".into(),
1365            summary: "pounce: complementarity: no formulation produced an answer".into(),
1366            solves: vec![ComplementaritySolveInfo {
1367                stage: "exact product".into(),
1368                tau: None,
1369                status: "Not_Enough_Degrees_Of_Freedom".into(),
1370                iterations: 0,
1371                max_abs_product: None,
1372                seconds: Some(0.01),
1373            }],
1374            total_iterations: 0,
1375            max_abs_product: None,
1376            certificate: Some(ComplementarityCertificateInfo {
1377                class: "none".into(),
1378                n_pairs: 1,
1379                n_biactive: 0,
1380                n_neither: 0,
1381                multipliers_unique: true,
1382                residual: None,
1383                resid_tol: Some(2e-4),
1384                tight: false,
1385                primal_violation: Some(4.6e-4),
1386                feasible: false,
1387            }),
1388            b_stationarity: Some(ComplementarityBStationarityInfo {
1389                verdict: "not_checked".into(),
1390                reason: Some("the point is infeasible".into()),
1391                n_biactive: 0,
1392                branches_solved: 0,
1393                enumerated: true,
1394            }),
1395            escapes: 0,
1396            lifted: 0,
1397            natural_residual: Some(7.8e-5),
1398            polish: Some(ComplementarityPolishInfo {
1399                taken: false,
1400                residual_before: Some(7.8e-5),
1401                residual_after: None,
1402                reason: Some("Infeasible_Problem_Detected".into()),
1403            }),
1404            polish_threshold: Some(1e-7),
1405        });
1406        let report = b.finish();
1407        let text = serde_json::to_string(&report).unwrap();
1408        assert!(text.contains("\"residual\":null"), "{text}");
1409        let back: SolveReport = serde_json::from_str(&text).expect("round trip");
1410        let c = back.complementarity.expect("block");
1411        assert_eq!(c.reported, "verdict");
1412        assert_eq!(c.solves[0].max_abs_product, None);
1413        assert_eq!(c.certificate.unwrap().primal_violation, Some(4.6e-4));
1414        assert_eq!(c.b_stationarity.unwrap().verdict, "not_checked");
1415        assert_eq!(c.natural_residual, Some(7.8e-5));
1416        assert_eq!(c.solves[0].seconds, Some(0.01));
1417        let p = c.polish.expect("polish");
1418        assert!(!p.taken);
1419        assert_eq!(p.residual_after, None);
1420
1421        // A block written before `b_stationarity`, `escapes`,
1422        // `natural_residual`, `polish` and a solve's `seconds` existed still
1423        // reads back, with each absent.
1424        let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
1425        let blk = old["complementarity"].as_object_mut().unwrap();
1426        for k in [
1427            "b_stationarity",
1428            "escapes",
1429            "lifted",
1430            "natural_residual",
1431            "polish",
1432            "polish_threshold",
1433        ] {
1434            blk.remove(k);
1435        }
1436        blk["solves"][0].as_object_mut().unwrap().remove("seconds");
1437        let back: SolveReport = serde_json::from_value(old).expect("pre-field report");
1438        let c = back.complementarity.expect("block");
1439        assert!(c.b_stationarity.is_none());
1440        assert_eq!(c.escapes, 0);
1441        assert_eq!(c.lifted, 0);
1442        assert!(c.natural_residual.is_none() && c.polish.is_none());
1443        assert!(c.polish_threshold.is_none());
1444        assert_eq!(c.solves[0].seconds, None);
1445
1446        // A report without the block omits the key and still reads back.
1447        let plain = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
1448        let text = serde_json::to_string(&plain).unwrap();
1449        assert!(!text.contains("complementarity"), "{text}");
1450        let back: SolveReport = serde_json::from_str(&text).unwrap();
1451        assert!(back.complementarity.is_none());
1452    }
1453
1454    #[test]
1455    fn iso_formatter_matches_known_epochs() {
1456        // Epoch.
1457        assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
1458        // 2000-01-01T00:00:00Z = 946684800 seconds.
1459        assert_eq!(
1460            unix_nanos_to_iso(946_684_800_000_000_000),
1461            "2000-01-01T00:00:00.000Z",
1462        );
1463        // 2024-02-29T12:34:56.789Z (leap-year sanity check).
1464        // Seconds: (2024 - 1970) * 365.25 days * 86400 ≈ 1709209296 — let's compute exactly.
1465        // Days from 1970-01-01 to 2024-02-29: 19782.
1466        // 19782 * 86400 = 1709164800. Plus 12*3600 + 34*60 + 56 = 45296.
1467        // Total = 1709210096.
1468        let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
1469        assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
1470    }
1471
1472    #[test]
1473    fn target_triple_resolves_to_real_triple_not_unknown() {
1474        // Fail-first: before the build.rs re-export this constant read
1475        // `option_env!("TARGET")`, which is `None` at crate-source compile
1476        // time (Cargo only exposes TARGET to build scripts), so it was always
1477        // "unknown". The build.rs now re-exports TARGET as
1478        // POUNCE_TARGET_TRIPLE, which resolves it to the real build triple.
1479        assert_ne!(
1480            TARGET_TRIPLE, "unknown",
1481            "build.rs should re-export the build target triple"
1482        );
1483        // A real triple has the `arch-vendor-os[-abi]` shape (>= 2 dashes).
1484        assert!(
1485            TARGET_TRIPLE.matches('-').count() >= 2,
1486            "unexpected target triple: {TARGET_TRIPLE:?}"
1487        );
1488
1489        // And it must propagate into the finished report.
1490        let b = ReportBuilder::new(
1491            ReportDetail::Summary,
1492            InputDescriptor::NlFile {
1493                path: PathBuf::from("/tmp/foo.nl"),
1494                size_bytes: None,
1495            },
1496        );
1497        let report = b.finish();
1498        assert_eq!(report.fair_metadata.solver.target_triple, TARGET_TRIPLE);
1499        assert_ne!(report.fair_metadata.solver.target_triple, "unknown");
1500    }
1501
1502    /// The Phase-0a work / pivoting fields and the Schur breakdown survive a
1503    /// JSON round trip, and a `linear_solver` object written before those
1504    /// fields existed still deserializes (the change is additive within v1).
1505    #[test]
1506    fn linear_solver_summary_round_trips_and_stays_backward_compatible() {
1507        use pounce_linsol::summary::SchurSummary;
1508        let summary = LinearSolverSummary {
1509            solver_name: "feral".into(),
1510            n_factors: 3,
1511            total_factor_secs: 0.5,
1512            total_factor_flops: 1.0e6,
1513            total_delayed_cols: 7,
1514            total_two_by_two: 2,
1515            last_ordering: Some("metis".into()),
1516            last_max_front_rows: Some(40),
1517            schur: Some(SchurSummary {
1518                n_eliminated: 100,
1519                n_schur: 4,
1520                n_factors: 3,
1521                ..Default::default()
1522            }),
1523            ..Default::default()
1524        };
1525        let info = LinearSolverSummaryInfo::from(summary);
1526        let json = serde_json::to_string(&info).expect("serialize");
1527        let back: LinearSolverSummaryInfo = serde_json::from_str(&json).expect("deserialize");
1528        assert_eq!(back.total_delayed_cols, 7);
1529        assert_eq!(back.total_two_by_two, 2);
1530        assert_eq!(back.last_ordering.as_deref(), Some("metis"));
1531        assert_eq!(back.last_max_front_rows, Some(40));
1532        let sch = back.schur.expect("schur survives");
1533        assert_eq!((sch.n_eliminated, sch.n_schur, sch.n_factors), (100, 4, 3));
1534        // Unset optionals are omitted, not written as null.
1535        assert!(!json.contains("last_peak_bytes"));
1536
1537        let old = r#"{"solver_name":"feral","n_factors":2,"n_pattern_reuse":1,"n_pattern_changes":1,"last_nnz_l":10}"#;
1538        let back: LinearSolverSummaryInfo = serde_json::from_str(old).expect("old report parses");
1539        assert_eq!(back.n_factors, 2);
1540        assert_eq!(back.total_factor_secs, 0.0);
1541        assert!(back.schur.is_none());
1542        assert!(back.restoration.is_none());
1543    }
1544
1545    #[test]
1546    fn report_serializes_round_trip() {
1547        let mut b = ReportBuilder::new(
1548            ReportDetail::Summary,
1549            InputDescriptor::NlFile {
1550                path: PathBuf::from("/tmp/foo.nl"),
1551                size_bytes: Some(123),
1552            },
1553        );
1554        b.problem.n_variables = 5;
1555        b.problem.n_constraints = 4;
1556        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
1557        b.solution.solve_result_num = 0;
1558        b.solution.objective = 0.55;
1559        b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
1560        b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
1561        b.stats.iteration_count = 9;
1562
1563        let report = b.finish();
1564        let json = serde_json::to_string_pretty(&report).expect("serialize");
1565        let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
1566        assert_eq!(back.schema, "pounce.solve-report/v1");
1567        assert_eq!(back.problem.n_variables, 5);
1568        assert_eq!(back.solution.x.len(), 5);
1569        assert!(matches!(
1570            back.solution.status,
1571            ApplicationReturnStatus::SolveSucceeded,
1572        ));
1573    }
1574
1575    /// gh #767: the report is the FAIR-aligned machine surface, and a
1576    /// consumer keyed on Ipopt's own enumerator spelling — which is what
1577    /// CUTEst tables, the reference JSONs and the CLI's `Status:` line all
1578    /// use — must be able to compare a field literally. `status` carries
1579    /// the Rust variant name (`SolveSucceeded`); `status_upstream` carries
1580    /// `Solve_Succeeded`. A consumer that compared the former against the
1581    /// latter's spelling matched nothing and read every solve as a failure.
1582    #[test]
1583    fn report_carries_the_upstream_status_spelling_beside_the_rust_one() {
1584        let mut b = ReportBuilder::new(
1585            ReportDetail::Summary,
1586            InputDescriptor::Builtin {
1587                name: "rosenbrock".into(),
1588            },
1589        );
1590        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
1591        let json = serde_json::to_value(b.finish()).expect("serialize");
1592        assert_eq!(json["solution"]["status"], "SolveSucceeded");
1593        assert_eq!(json["solution"]["status_upstream"], "Solve_Succeeded");
1594    }
1595
1596    /// The derived field tracks whatever `status` was last set to — it is
1597    /// computed in `finish`, so a caller cannot leave it stale or set the
1598    /// two to different verdicts.
1599    #[test]
1600    fn upstream_status_spelling_is_derived_not_stored() {
1601        for status in [
1602            ApplicationReturnStatus::MaximumIterationsExceeded,
1603            ApplicationReturnStatus::InfeasibleProblemDetected,
1604            ApplicationReturnStatus::SolvedToAcceptableLevel,
1605        ] {
1606            let mut b = ReportBuilder::new(
1607                ReportDetail::Summary,
1608                InputDescriptor::Builtin { name: "x".into() },
1609            );
1610            // Deliberately wrong; `finish` must overwrite it.
1611            b.solution.status_upstream = "Solve_Succeeded".to_string();
1612            b.solution.status = status;
1613            let report = b.finish();
1614            assert_eq!(report.solution.status_upstream, status.upstream_name());
1615        }
1616    }
1617
1618    #[test]
1619    fn summary_detail_omits_iterations_block() {
1620        let mut b = ReportBuilder::new(
1621            ReportDetail::Summary,
1622            InputDescriptor::Builtin {
1623                name: "rosenbrock".into(),
1624            },
1625        );
1626        let mut stats = SolveStatistics::default();
1627        stats.iterations.push(IterRecord {
1628            iter: 0,
1629            objective: 1.0,
1630            ..IterRecord::default()
1631        });
1632        b.ingest_stats(&stats);
1633        let r = b.finish();
1634        assert!(
1635            r.iterations.is_empty(),
1636            "Summary detail should drop iter history; got {} rows",
1637            r.iterations.len()
1638        );
1639        // And the JSON should not include the key at all (skip-empty).
1640        let json = serde_json::to_string(&r).unwrap();
1641        assert!(!json.contains("\"iterations\":"), "json: {json}");
1642    }
1643
1644    #[test]
1645    fn full_detail_includes_iteration_rows() {
1646        let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
1647        let mut stats = SolveStatistics::default();
1648        stats.iterations.push(IterRecord {
1649            iter: 0,
1650            objective: 1.0,
1651            inf_pr: 0.5,
1652            ..IterRecord::default()
1653        });
1654        stats.iterations.push(IterRecord {
1655            iter: 1,
1656            objective: 0.5,
1657            inf_pr: 0.1,
1658            ..IterRecord::default()
1659        });
1660        b.ingest_stats(&stats);
1661        let r = b.finish();
1662        assert_eq!(r.iterations.len(), 2);
1663        assert_eq!(r.iterations[0].iter, 0);
1664        assert_eq!(r.iterations[1].iter, 1);
1665    }
1666
1667    #[test]
1668    fn detail_parser_accepts_known_values() {
1669        assert_eq!(
1670            ReportDetail::parse("summary").unwrap(),
1671            ReportDetail::Summary
1672        );
1673        assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
1674        assert!(ReportDetail::parse("verbose").is_err());
1675    }
1676
1677    #[test]
1678    fn diverging_iterates_maps_to_unbounded_range() {
1679        use ApplicationReturnStatus::*;
1680        // M12 regression: DivergingIterates is Ipopt's unboundedness
1681        // signal and must land in the AMPL 300 "unbounded" range, not
1682        // the 400 "limit" range — matching upstream Ipopt's ASL driver
1683        // and the CLI convex path (QpStatus::DualInfeasible → 300).
1684        assert_eq!(status_to_solve_result_num(DivergingIterates), 300);
1685
1686        // Lock the surrounding range convention so the fix can't silently
1687        // drift back: solved / infeasible / limit / failure buckets.
1688        assert_eq!(status_to_solve_result_num(SolveSucceeded), 0);
1689        assert_eq!(status_to_solve_result_num(InfeasibleProblemDetected), 200);
1690        assert_eq!(
1691            status_to_solve_result_num(MaximumIterationsExceeded),
1692            400,
1693            "iteration limit stays in the 400 range",
1694        );
1695        assert_eq!(
1696            status_to_solve_result_num(SearchDirectionBecomesTooSmall),
1697            400,
1698        );
1699        assert_eq!(status_to_solve_result_num(RestorationFailed), 500);
1700    }
1701
1702    /// gh #591: an accepted (reduced-accuracy) solve must land in AMPL's
1703    /// `0..=99` *solved* band with Ipopt's own code, `1`. In the 100 band
1704    /// Pyomo's legacy `.sol` reader loads the result as
1705    /// `status=warning, termination_condition=optimal` and logs a warning,
1706    /// while the identical Ipopt solve loads as `status=ok` — so a
1707    /// solver-swappable client that treats `status == ok` as part of its
1708    /// accepted-solve contract had to special-case POUNCE.
1709    #[test]
1710    fn solved_to_acceptable_level_is_in_the_solved_band_like_ipopt() {
1711        use ApplicationReturnStatus::*;
1712        let code = status_to_solve_result_num(SolvedToAcceptableLevel);
1713        assert_eq!(
1714            code, 1,
1715            "Ipopt's ASL driver emits 1 for STOP_AT_ACCEPTABLE_POINT",
1716        );
1717        assert!(
1718            (0..=99).contains(&code),
1719            "must be in the solved band Pyomo maps to status=ok, got {code}",
1720        );
1721        // Still distinguishable from a full-accuracy solve: the two codes
1722        // differ, and the status name carries the distinction verbatim into
1723        // the `.sol` message line.
1724        assert_ne!(code, status_to_solve_result_num(SolveSucceeded));
1725
1726        // `FeasiblePointFound` is also in the solved band, for its own
1727        // reason — see `a_square_problem_feasible_point_is_in_the_solved_band`.
1728        assert_ne!(code, status_to_solve_result_num(FeasiblePointFound));
1729    }
1730
1731    /// POUNCE emits `FeasiblePointFound` only for square problems — the
1732    /// status has one production site (`min_c_1nrm.rs`) behind one gate
1733    /// (`resto_inner_solver.rs`), and that gate is `is_square_problem &&
1734    /// ...`. That is exactly Ipopt's meaning, and on a square problem a
1735    /// feasible point *is* the solution, so the code is Ipopt's own `2`.
1736    ///
1737    /// The band, not the number, is what breaks: at `100` Pyomo's v2 ASL
1738    /// reader returns `TerminationCondition.error` for a correct solve
1739    /// (gh #815 — an IDAES flowsheet solved to a 2.2e-06 constraint
1740    /// violation and reported as a solver error), and the legacy reader
1741    /// returns `status=warning`, the same gh #591 complaint that moved
1742    /// `SolvedToAcceptableLevel` out of the band.
1743    #[test]
1744    fn a_square_problem_feasible_point_is_in_the_solved_band() {
1745        use ApplicationReturnStatus::*;
1746        let code = status_to_solve_result_num(FeasiblePointFound);
1747        assert_eq!(
1748            code, 2,
1749            "Ipopt's ASL driver emits 2 for FEASIBLE_POINT_FOUND"
1750        );
1751        assert!(
1752            (0..=99).contains(&code),
1753            "must be in the solved band both Pyomo readers accept, got {code}",
1754        );
1755        // Still its own verdict: distinguishable from both other members of
1756        // the band, with the distinction carried verbatim in the status name
1757        // and the `.sol` message line.
1758        assert_ne!(code, status_to_solve_result_num(SolveSucceeded));
1759        assert_ne!(code, status_to_solve_result_num(SolvedToAcceptableLevel));
1760    }
1761
1762    #[test]
1763    fn result_id_is_unique_and_time_ordered() {
1764        let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
1765        std::thread::sleep(std::time::Duration::from_millis(2));
1766        let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
1767        assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
1768        assert!(
1769            b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
1770            "second result_id should sort after first"
1771        );
1772    }
1773}