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