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}
108
109/// The second-opinion ladder's record: which rungs ran, which (if any) was
110/// promoted, and **what the base solve did before any of it** (gh #850).
111///
112/// # Why the base solve's numbers are the point
113///
114/// On a promotion the reported `status` and `statistics.iteration_count` both
115/// become the promoted rung's, and nothing else in the report says the base
116/// solver failed. That makes a *lost* solve indistinguishable from a faster
117/// one, and it is worse than a gap in the evidence: it produces positive
118/// evidence for the wrong conclusion.
119///
120/// The case that exposed it is `square_flowsheet_resto`, where `v0.10.0`'s
121/// base solver converged in 116 iterations and HEAD's does not converge at all
122/// — `RestorationFailed` at 131 — with the answer coming from a ladder rung
123/// (`start_point_perturbation=1e-2`) added in the same release, which promotes
124/// at 54. `scripts/sweep-fixtures.sh` read that as `116 -> 54`, **a 2× win**.
125///
126/// The cost is understated on top of that: `statistics.iteration_count` is the
127/// promoted rung's alone, so the fixture's true cost is `131 + 54`, 3.4× what
128/// the report says. [`Self::total_iteration_count`] is the honest number.
129///
130/// This is the same shape of invisibility the sweep's engine column was added
131/// to close, and CLAUDE.md's rule applies verbatim: a line whose only moving
132/// field is "solved directly" → "promoted from a rung" is a trajectory change,
133/// and is as reportable as a moved iteration count.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SecondOpinionInfo {
136    /// Rung labels actually run, in order.
137    pub tried: Vec<String>,
138    /// The rung whose re-solve was promoted, or `None` when the original
139    /// verdict survived every rung and shipped unchanged.
140    #[serde(skip_serializing_if = "Option::is_none", default)]
141    pub promoted_by: Option<String>,
142    /// The verdict the ladder was opened on, before any rung ran.
143    pub base_status: String,
144    /// Iterations the base solve spent. Not included in
145    /// `statistics.iteration_count` when a rung was promoted.
146    pub base_iteration_count: usize,
147    /// Iterations each rung in `tried` spent, in the same order.
148    pub rung_iteration_counts: Vec<usize>,
149    /// Base plus every rung: what the solve actually cost.
150    pub total_iteration_count: usize,
151}
152
153/// Serializable mirror of [`pounce_linsol::summary::LinearSolverSummary`].
154/// Lives in the CLI crate (rather than `pounce-linsol`) so the linsol
155/// trait crate stays serde-free. Field shape is identical; serde
156/// defaults keep it forward-compatible with future additions.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct LinearSolverSummaryInfo {
159    pub solver_name: String,
160    pub n_factors: u64,
161    pub n_pattern_reuse: u64,
162    pub n_pattern_changes: u64,
163    #[serde(skip_serializing_if = "Option::is_none", default)]
164    pub max_fill_ratio: Option<f64>,
165    #[serde(skip_serializing_if = "Option::is_none", default)]
166    pub min_abs_pivot: Option<f64>,
167    #[serde(skip_serializing_if = "Option::is_none", default)]
168    pub max_abs_pivot: Option<f64>,
169    /// `(positive, negative, zero)` inertia of the final factorisation.
170    #[serde(skip_serializing_if = "Option::is_none", default)]
171    pub last_inertia: Option<(usize, usize, usize)>,
172    #[serde(skip_serializing_if = "Option::is_none", default)]
173    pub last_nnz_a: Option<usize>,
174    #[serde(skip_serializing_if = "Option::is_none", default)]
175    pub last_nnz_l: Option<usize>,
176}
177
178impl From<LinearSolverSummary> for LinearSolverSummaryInfo {
179    fn from(s: LinearSolverSummary) -> Self {
180        Self {
181            solver_name: s.solver_name,
182            n_factors: s.n_factors,
183            n_pattern_reuse: s.n_pattern_reuse,
184            n_pattern_changes: s.n_pattern_changes,
185            max_fill_ratio: s.max_fill_ratio,
186            min_abs_pivot: s.min_abs_pivot,
187            max_abs_pivot: s.max_abs_pivot,
188            last_inertia: s.last_inertia,
189            last_nnz_a: s.last_nnz_a,
190            last_nnz_l: s.last_nnz_l,
191        }
192    }
193}
194
195/// FAIR-aligned provenance block. The four FAIR principles
196/// (Wilkinson et al., 2016) map onto fields here as:
197/// * **F**indable: `result_id` (unique per solve), `created_at_iso`.
198/// * **A**ccessible: this JSON file is the artifact — no protocol
199///   gating, plain text on disk.
200/// * **I**nteroperable: schema versioned, types are JSON primitives,
201///   units documented in field doc comments.
202/// * **R**eusable: `solver`, `license`, `input` describe what was
203///   solved with what code, enough to reproduce.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct FairMetadata {
206    /// Unique per-solve identifier. Composed as
207    /// `<unix_nanos>-<process_id>` so it is monotonically ordered
208    /// within a process and globally unique across processes.
209    pub result_id: String,
210    /// Solve start time as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SS.sssZ`).
211    pub created_at_iso: String,
212    /// Same instant in Unix nanoseconds (since 1970-01-01 UTC).
213    /// Provided alongside the ISO string for callers that prefer
214    /// integer arithmetic over date parsing.
215    pub created_at_unix_nanos: i128,
216    /// Wallclock seconds the solve took. Mirrors
217    /// [`SolveStatistics::total_wallclock_time_secs`].
218    pub elapsed_seconds: Number,
219    /// Solver identity — name + version + (best-effort) git commit.
220    pub solver: SolverIdentity,
221    /// SPDX license string. Always `"EPL-2.0"` for this crate.
222    pub license: String,
223    /// Input descriptor. `kind` is `nl-file`, `builtin`, or
224    /// `tnlp-direct` (for library callers).
225    pub input: InputDescriptor,
226    /// Solve-affecting environment variables present in the process
227    /// environment at report time (`POUNCE_FERAL_*` numerics knobs and
228    /// the legacy `FERAL_PIVTOL` / `FERAL_PARALLEL`). These alter the
229    /// factorization or parallelism and can otherwise silently differ a
230    /// run between two machines — e.g. one with `POUNCE_FERAL_PIVTOL`
231    /// exported in a shell profile — with nothing in the report saying
232    /// so (pounce#235). Recorded for reproducibility (the FAIR **R**
233    /// principle). Presence here means the variable was set, not
234    /// necessarily that it took effect: an explicit `OptionsList` setting
235    /// (e.g. a `feral_pivtol` in an options file) takes precedence over
236    /// the env fallback. Empty (and omitted from the JSON) when none are
237    /// set. Additive — older `pounce.solve-report/v1` JSON without this
238    /// field deserializes unchanged.
239    #[serde(skip_serializing_if = "Vec::is_empty", default)]
240    pub environment: Vec<EnvOverride>,
241}
242
243/// One solve-affecting environment variable and its value, as captured
244/// into [`FairMetadata::environment`].
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct EnvOverride {
247    /// Variable name, e.g. `POUNCE_FERAL_PIVTOL`.
248    pub name: String,
249    /// Its value verbatim, as read from the environment.
250    pub value: String,
251}
252
253/// Environment variables that change pounce's numerics or parallelism —
254/// the ones worth recording for reproducibility. Deliberately excludes the
255/// `POUNCE_DBG_*` debug gates (they only add diagnostic output, never
256/// altering the result) and compile-time / logging vars. Kept in a fixed
257/// order so [`capture_solve_env_overrides`] is deterministic.
258const SOLVE_AFFECTING_ENV_VARS: &[&str] = &[
259    "POUNCE_FERAL_ORDERING",
260    "POUNCE_FERAL_SCALING",
261    "POUNCE_FERAL_PIVTOL",
262    "POUNCE_FERAL_REFINE",
263    "POUNCE_FERAL_CASCADE_BREAK",
264    "POUNCE_FERAL_FMA",
265    "POUNCE_FERAL_SINGULAR_PIVOT_FLOOR",
266    "POUNCE_FERAL_MIN_PAR_FLOPS",
267    // Legacy aliases without the `POUNCE_` prefix (still honored by the
268    // FERAL backend): the deprecated pivot-threshold spelling and the
269    // process-wide internal-parallelism switch.
270    "FERAL_PIVTOL",
271    "FERAL_PARALLEL",
272];
273
274/// Snapshot the solve-affecting environment variables ([`SOLVE_AFFECTING_ENV_VARS`])
275/// that are currently set, for [`FairMetadata::environment`]. Reads the
276/// process environment, so call it in the solving process. Returns them in
277/// the fixed list order; absent variables are simply skipped.
278pub fn capture_solve_env_overrides() -> Vec<EnvOverride> {
279    SOLVE_AFFECTING_ENV_VARS
280        .iter()
281        .filter_map(|&name| {
282            std::env::var(name).ok().map(|value| EnvOverride {
283                name: name.to_string(),
284                value,
285            })
286        })
287        .collect()
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SolverIdentity {
292    pub name: String,
293    pub version: String,
294    /// Git commit hash, captured at build time from the
295    /// `POUNCE_GIT_COMMIT` environment variable. `None` if the build
296    /// environment didn't set it — set via
297    /// `POUNCE_GIT_COMMIT=$(git rev-parse HEAD) cargo build`.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub git_commit: Option<String>,
300    /// Build target triple (e.g. `x86_64-apple-darwin`). Captured at
301    /// build time from `TARGET` (Cargo standard env var).
302    pub target_triple: String,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(tag = "kind", rename_all = "kebab-case")]
307pub enum InputDescriptor {
308    NlFile {
309        path: PathBuf,
310        #[serde(skip_serializing_if = "Option::is_none")]
311        size_bytes: Option<u64>,
312    },
313    /// A Conic Benchmark Format (`.cbf`) instance — e.g. a CBLIB problem
314    /// solved through the convex conic driver.
315    CbfFile {
316        path: PathBuf,
317        #[serde(skip_serializing_if = "Option::is_none")]
318        size_bytes: Option<u64>,
319    },
320    Builtin {
321        name: String,
322    },
323    TnlpDirect,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct ProblemInfo {
328    pub n_variables: Index,
329    pub n_constraints: Index,
330    pub n_objectives: Index,
331    pub minimize: bool,
332    /// Number of non-zeros declared by the TNLP for the constraint
333    /// Jacobian. `None` if not exposed by the input path.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub nnz_jac_g: Option<Index>,
336    /// Number of non-zeros declared for the Lagrangian Hessian.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub nnz_h_lag: Option<Index>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct SolutionInfo {
343    /// Which engine produced this verdict: `"cvx-qp"`, `"cvx-qcqp"`,
344    /// `"qp-active-set"` or `"nlp"`.
345    ///
346    /// The `Selected solver:` banner names the engine *routing* chose, which
347    /// is not always the one that answered: a convex solve that declines its
348    /// own result hands the model to the NLP arm (gh #535), and the banner
349    /// has already been printed by then. `scripts/sweep-fixtures.sh` scraped
350    /// that banner because the report carried nothing better, so a reroute
351    /// left no trace in the sweep diff — the precise blind spot CLAUDE.md
352    /// names when it says a routing regression "used to leave no trace".
353    ///
354    /// Empty when a path does not set it, which a consumer should read as
355    /// "unknown" rather than as any particular arm.
356    #[serde(default, skip_serializing_if = "String::is_empty")]
357    pub engine: String,
358    /// `SolveSucceeded`, `MaximumIterationsExceeded`, etc. The string
359    /// form is the Rust enum variant name verbatim.
360    pub status: ApplicationReturnStatus,
361    /// The same verdict in upstream Ipopt's C enumerator spelling —
362    /// `Solve_Succeeded`, `Infeasible_Problem_Detected` — from
363    /// `IpReturnCodes_inc.h`.
364    ///
365    /// [`Self::status`] carries the Rust variant name, which is *not* the
366    /// name any Ipopt-facing consumer already keys off: CUTEst status
367    /// tables, `benchmarks/scripts/run_nl_bench.sh`, the reference JSONs
368    /// under `benchmarks/*/ipopt_ma57.json` and the CLI's own `Status:`
369    /// line all spell it with separators. A consumer comparing
370    /// `solution.status == "Solve_Succeeded"` against the report matched
371    /// nothing and silently classified every solve as a failure (gh #767).
372    /// This field is that spelling, so the comparison can be literal.
373    ///
374    /// Derived from [`Self::status`] by [`ReportBuilder::finish`] — never
375    /// set by a caller, so the two cannot disagree. Empty when read back
376    /// from a pre-#767 report.
377    #[serde(default)]
378    pub status_upstream: String,
379    /// AMPL-style solve-result code (Gay 2005, §5 p. 23 table).
380    pub solve_result_num: i32,
381    /// Final unscaled objective value (mirrors
382    /// `SolveStatistics::final_objective`). `NaN` if unknown.
383    pub objective: Number,
384    /// Final primal vector, length `problem.n_variables`. Empty if
385    /// not captured.
386    #[serde(skip_serializing_if = "Vec::is_empty", default)]
387    pub x: Vec<Number>,
388    /// Final dual (constraint multiplier) vector, length
389    /// `problem.n_constraints`.
390    #[serde(skip_serializing_if = "Vec::is_empty", default)]
391    pub lambda: Vec<Number>,
392    /// Optional sIPOPT-style suffix blocks (`sens_sol_state_1` etc.).
393    /// Stored as a flat map keyed by suffix name → list of
394    /// `(index, value)` pairs, matching the AMPL `.sol` shape.
395    /// Empty when no sensitivity / reduced-Hessian step ran.
396    #[serde(skip_serializing_if = "Vec::is_empty", default)]
397    pub suffixes: Vec<SolutionSuffix>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct SolutionSuffix {
402    pub name: String,
403    /// `"var" | "con" | "obj" | "problem"` per AMPL convention.
404    pub target: String,
405    /// `"int"` or `"real"`.
406    pub kind: String,
407    /// Dense values (length = target dimension); zero-filled for
408    /// slots the writer didn't populate. Real-typed values are stored
409    /// here; int-typed in `int_values`.
410    #[serde(skip_serializing_if = "Vec::is_empty", default)]
411    pub values: Vec<Number>,
412    #[serde(skip_serializing_if = "Vec::is_empty", default)]
413    pub int_values: Vec<Index>,
414}
415
416/// NaN, for a residual slot that was never filled in.
417fn uncomputed() -> Number {
418    Number::NAN
419}
420
421/// Accept `null` for a residual the solve never computed.
422///
423/// `SolveStatistics` defaults its residual fields to NaN rather than `0.0`, so
424/// that "the convergence check never ran" is distinguishable from "converged
425/// exactly". `serde_json` renders a non-finite float as `null`, so a report
426/// written for a solve that was refused during setup carries `null` in these
427/// slots. Without this the report round-trip fails — pounce would write
428/// reports its own `--cite` / studio / verify paths could not read back.
429fn null_as_nan<'de, D>(de: D) -> Result<Number, D::Error>
430where
431    D: serde::Deserializer<'de>,
432{
433    Ok(Option::<Number>::deserialize(de)?.unwrap_or_else(uncomputed))
434}
435
436/// Subset of `SolveStatistics` projected for the report. Mirrors the
437/// fields the existing console summary prints.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct StatisticsInfo {
440    pub iteration_count: Index,
441    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
442    pub final_objective: Number,
443    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
444    pub final_scaled_objective: Number,
445    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
446    pub final_dual_inf: Number,
447    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
448    pub final_constr_viol: Number,
449    /// Primal violation against the model **as declared**, before the convex
450    /// arm's `bound_relax_factor` widening (`qp_extract::BoundRelax`, gh
451    /// #744/#745).
452    ///
453    /// `final_constr_viol` measures the model the solver was HANDED, whose
454    /// inequality rows and variable box are widened by `min(factor,cap)·|b|`.
455    /// That is the model its convergence test is about and every acceptance
456    /// gate reads — and it is not how far the returned point sits outside the
457    /// model the caller wrote. On netlib `afiro` the point is `4.99e-06`
458    /// outside a declared row `b = 500` (exactly `1e-8·500`) while
459    /// `final_constr_viol` reads `8.68e-13`; `25fv47` reports `2.19e-11`
460    /// against `1.97e-05`.
461    ///
462    /// `NaN` when the solve applied no widening (the two coincide by
463    /// construction) or on a path that does not compute it — every NLP-arm
464    /// solve today. Additive to `pounce.solve-report/v1`: readers predating
465    /// it are unaffected.
466    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
467    pub final_declared_constr_viol: Number,
468    /// How far the returned point sits outside the **declared** variable box —
469    /// the box the caller wrote, before the `bound_relax_factor` widening.
470    /// Ipopt's `Variable bound violation`, and the box half of
471    /// `final_declared_constr_viol` reported on its own: maxed together, a box
472    /// violation and a row violation cannot be told apart.
473    ///
474    /// Variable bounds carry no scaling, so there is one number rather than a
475    /// scaled/unscaled pair. `NaN` on a path that does not compute it.
476    /// Additive to `pounce.solve-report/v1`: readers predating it are
477    /// unaffected.
478    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
479    pub final_declared_box_viol: Number,
480    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
481    pub final_compl: Number,
482    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
483    pub final_kkt_error: Number,
484    /// The aggregate the strict convergence gate tested (gh #528): as
485    /// `final_kkt_error`, but counting each constraint row's residual only
486    /// where it exceeds what that row can represent in floating point. Equal
487    /// to `final_kkt_error` unless a row is at its own resolution limit.
488    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
489    pub final_kkt_error_above_noise: Number,
490    pub num_obj_evals: Index,
491    pub num_constr_evals: Index,
492    pub num_obj_grad_evals: Index,
493    pub num_constr_jac_evals: Index,
494    pub num_hess_evals: Index,
495    pub total_wallclock_time_secs: Number,
496    pub restoration_calls: Index,
497    pub restoration_inner_iters: Index,
498    pub restoration_outer_iters: Index,
499    pub restoration_wall_secs: Number,
500    /// Successful linear-solver quality escalations over the whole solve,
501    /// restoration sub-solves included (gh#857). `0` on paths that never
502    /// escalate. `serde(default)` so a report written before this field
503    /// existed still deserializes — it reads as "no escalations", which
504    /// is wrong-but-harmless for an old file and correct for every new
505    /// one.
506    #[serde(default)]
507    pub quality_escalations: Index,
508    /// The solve saw the gh#884 biactive dual-divergence signature: at one
509    /// and the same iterate a converged primal, a scale-relative step at
510    /// zero, and an *unscaled* dual infeasibility far above `dual_inf_tol`.
511    /// Reported whether or not a retry ran or promoted, because the
512    /// distinction it draws — the multipliers ran away on a settled
513    /// iterate, as against the iterate itself never settling — is not
514    /// visible in any other field. `serde(default)` for the same reason as
515    /// `quality_escalations`.
516    #[serde(default)]
517    pub dual_divergence_signature: bool,
518    /// A gh#884 dual-divergence retry ran *and* its answer was returned.
519    /// `false` both when no retry ran and when one ran and lost — in the
520    /// latter case every other field here describes the base attempt.
521    #[serde(default)]
522    pub dual_divergence_retry_promoted: bool,
523}
524
525/// Builder collecting the inputs for a [`SolveReport`]. The CLI
526/// drivers populate one of these as they walk through the solve and
527/// `finish()` it at the end.
528pub struct ReportBuilder {
529    detail: ReportDetail,
530    started_at: SystemTime,
531    started_unix_nanos: i128,
532    pub input: InputDescriptor,
533    pub problem: ProblemInfo,
534    pub solution: SolutionInfo,
535    pub stats: StatisticsInfo,
536    pub iterations: Vec<IterRecord>,
537    pub linear_solver: Option<LinearSolverSummaryInfo>,
538    pub second_opinion: Option<SecondOpinionInfo>,
539}
540
541impl ReportBuilder {
542    pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
543        let now = SystemTime::now();
544        let nanos = now
545            .duration_since(UNIX_EPOCH)
546            .map(|d| d.as_nanos() as i128)
547            .unwrap_or(0);
548        Self {
549            detail,
550            started_at: now,
551            started_unix_nanos: nanos,
552            input,
553            problem: ProblemInfo {
554                n_variables: 0,
555                n_constraints: 0,
556                n_objectives: 0,
557                minimize: true,
558                nnz_jac_g: None,
559                nnz_h_lag: None,
560            },
561            solution: SolutionInfo {
562                engine: String::new(),
563                status: ApplicationReturnStatus::InternalError,
564                // Overwritten from `status` by `finish`; see the field docs.
565                status_upstream: String::new(),
566                solve_result_num: 500,
567                // 0.0 (not NaN) so JSON round-trips. Callers that
568                // need "unknown objective" semantics check
569                // `statistics.iteration_count > 0` first.
570                objective: 0.0,
571                x: Vec::new(),
572                lambda: Vec::new(),
573                suffixes: Vec::new(),
574            },
575            stats: empty_stats(),
576            iterations: Vec::new(),
577            linear_solver: None,
578            second_opinion: None,
579        }
580    }
581
582    /// Record what the second-opinion ladder did. Called only when it ran; a
583    /// verdict that opens no ladder leaves this `None` and the field out of
584    /// the JSON entirely (gh #850).
585    pub fn set_second_opinion(&mut self, info: SecondOpinionInfo) {
586        self.second_opinion = Some(info);
587    }
588
589    /// Attach a linear-solver post-mortem. Called once per solve after
590    /// `optimize_tnlp` returns and before [`Self::finish`].
591    pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
592        self.linear_solver = Some(summary.into());
593    }
594
595    /// Pull `iteration_count`, `final_*`, and counters into the
596    /// `stats` slot; copy `iterations` only if detail = Full.
597    pub fn ingest_stats(&mut self, src: &SolveStatistics) {
598        self.stats = StatisticsInfo {
599            iteration_count: src.iteration_count,
600            final_objective: src.final_objective,
601            final_scaled_objective: src.final_scaled_objective,
602            final_dual_inf: src.final_dual_inf,
603            final_constr_viol: src.final_constr_viol,
604            final_declared_constr_viol: src.final_declared_constr_viol,
605            final_declared_box_viol: src.final_declared_box_viol,
606            final_compl: src.final_compl,
607            final_kkt_error: src.final_kkt_error,
608            final_kkt_error_above_noise: src.final_kkt_error_above_noise,
609            num_obj_evals: src.num_obj_evals,
610            num_constr_evals: src.num_constr_evals,
611            num_obj_grad_evals: src.num_obj_grad_evals,
612            num_constr_jac_evals: src.num_constr_jac_evals,
613            num_hess_evals: src.num_hess_evals,
614            total_wallclock_time_secs: src.total_wallclock_time_secs,
615            restoration_calls: src.restoration_calls,
616            restoration_inner_iters: src.restoration_inner_iters,
617            restoration_outer_iters: src.restoration_outer_iters,
618            restoration_wall_secs: src.restoration_wall_secs,
619            quality_escalations: src.quality_escalations,
620            dual_divergence_signature: src.dual_divergence_signature,
621            dual_divergence_retry_promoted: src.dual_divergence_retry_promoted,
622        };
623        if matches!(self.detail, ReportDetail::Full) {
624            self.iterations = src.iterations.clone();
625        }
626    }
627
628    pub fn finish(self) -> SolveReport {
629        let elapsed = self
630            .started_at
631            .elapsed()
632            .map(|d| d.as_secs_f64())
633            .unwrap_or(0.0);
634        // Derived here rather than at each call site: every producer of a
635        // report (CLI, C interface, Python bindings, the CBF driver) sets
636        // `solution.status` and none of them can forget the upstream
637        // spelling, nor set one that disagrees with the other (gh #767).
638        let mut solution = self.solution;
639        solution.status_upstream = solution.status.upstream_name().to_string();
640        let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
641        let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
642
643        SolveReport {
644            schema: "pounce.solve-report/v1".to_string(),
645            fair_metadata: FairMetadata {
646                result_id,
647                created_at_iso,
648                created_at_unix_nanos: self.started_unix_nanos,
649                elapsed_seconds: elapsed,
650                solver: SolverIdentity {
651                    name: "pounce".to_string(),
652                    version: env!("CARGO_PKG_VERSION").to_string(),
653                    git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
654                    target_triple: TARGET_TRIPLE.to_string(),
655                },
656                license: "EPL-2.0".to_string(),
657                input: self.input,
658                environment: capture_solve_env_overrides(),
659            },
660            problem: self.problem,
661            solution,
662            statistics: self.stats,
663            iterations: self.iterations,
664            linear_solver: self.linear_solver,
665            second_opinion: self.second_opinion,
666        }
667    }
668}
669
670/// The build target triple (e.g. `aarch64-apple-darwin`).
671///
672/// Cargo only exposes `TARGET` to *build scripts*, not to crate source, so
673/// `option_env!("TARGET")` here is always `None`. Our `build.rs` re-exports
674/// the build script's `TARGET` as `POUNCE_TARGET_TRIPLE`, which we read
675/// instead. Falls back to "unknown" if the build script did not run (e.g.
676/// some non-Cargo tooling).
677const TARGET_TRIPLE: &str = match option_env!("POUNCE_TARGET_TRIPLE") {
678    Some(t) => t,
679    None => "unknown",
680};
681
682fn empty_stats() -> StatisticsInfo {
683    // All scalar fields start at 0.0 (not NaN) so the report
684    // round-trips through `serde_json` — JSON has no NaN literal, and
685    // serde_json's default is to write `null` for NaN, which then
686    // fails to deserialize back into `Number`. Callers reading these
687    // pre-solve treat `iteration_count == 0` as "no solve yet".
688    StatisticsInfo {
689        iteration_count: 0,
690        final_objective: 0.0,
691        final_scaled_objective: 0.0,
692        final_dual_inf: 0.0,
693        final_constr_viol: 0.0,
694        // not "uncomputed": this is the pre-solve placeholder, and 0.0 is
695        // what every residual beside it carries here.
696        final_declared_constr_viol: 0.0,
697        final_declared_box_viol: 0.0,
698        final_compl: 0.0,
699        final_kkt_error: 0.0,
700        final_kkt_error_above_noise: 0.0,
701        num_obj_evals: 0,
702        num_constr_evals: 0,
703        num_obj_grad_evals: 0,
704        num_constr_jac_evals: 0,
705        num_hess_evals: 0,
706        total_wallclock_time_secs: 0.0,
707        restoration_calls: 0,
708        restoration_inner_iters: 0,
709        restoration_outer_iters: 0,
710        restoration_wall_secs: 0.0,
711        quality_escalations: 0,
712        dual_divergence_signature: false,
713        dual_divergence_retry_promoted: false,
714    }
715}
716
717/// AMPL-style `solve_result_num` per Gay 2005 (Hooking Your Solver to
718/// AMPL §5, p. 23 table): 0 = solved, 100s = warning, 200s =
719/// infeasible, 300s = unbounded, 400s = limit reached, 500s = failure.
720/// Shared by the CLI and cinterface report writers so both encode the
721/// same int codes into `SolutionInfo::solve_result_num`.
722///
723/// `DivergingIterates` is Ipopt's unboundedness signal (the iterates run
724/// off to infinity), so it maps to the 300 "unbounded" range — matching
725/// upstream Ipopt's ASL driver and the CLI's own convex path, which
726/// reports `QpStatus::DualInfeasible` (unbounded) as 300 (`main.rs`). It
727/// is *not* a limit (400) condition.
728///
729/// `SolvedToAcceptableLevel` is `1`, not the 100 band, matching Ipopt's
730/// ASL driver exactly (`Ipopt/src/Apps/AmplSolver/AmplTNLP.cpp`:
731/// `STOP_AT_ACCEPTABLE_POINT` → `solve_result_num = 1`, message
732/// "Solved To Acceptable Level."). The band is what consumers key on, and
733/// the two bands are not interchangeable here: Pyomo's legacy `.sol`
734/// reader turns `0..=99` into `status=ok` but `100..=199` into
735/// `status=warning` with the same `termination_condition=optimal`, so the
736/// 100 band made Pyomo log a "Loading a SolverResults object with a
737/// warning status" warning on an accepted solve that Ipopt loads clean —
738/// breaking solver-swappable clients whose accepted-solve contract
739/// includes `status == ok` (gh #591). The reduced-accuracy convergence
740/// stays visible in the status name and the `.sol` message line; it just
741/// no longer reads as a warning.
742///
743/// `FeasiblePointFound` is `2`, Ipopt's own code, and therefore in the
744/// `0..=99` solved band. It used to be `100`, justified by the claim that
745/// the two statuses do not mean the same thing — that Ipopt returns
746/// `FEASIBLE_POINT_FOUND` only for a square problem, where a feasible
747/// point *is* the solution, while POUNCE used it more loosely for any
748/// usable feasible point that missed the convergence criteria.
749///
750/// That claim was false about POUNCE's own code. The status has exactly
751/// one production site: `min_c_1nrm.rs` returns
752/// `RestorationOutcome::FeasiblePointFound`, reached only through the
753/// gate at `resto_inner_solver.rs`, which is `is_square_problem && ...`.
754/// `is_square_problem()` (`ipopt_alg.rs`) is `c.x.dim() == c.y_c.dim()`,
755/// a port of `IpoptCalculatedQuantities::IsSquareProblem` — the same
756/// condition Ipopt uses. So POUNCE emits this status *only* for square
757/// problems, carrying Ipopt's meaning precisely, and on a square problem
758/// there is no further convergence criterion to miss: the objective is
759/// constant, so a feasible point is the solution.
760///
761/// The band is what consumers key on, and `100` was not a softer way of
762/// saying the same thing — it inverted the answer. Pyomo's v2 reader
763/// (`pyomo/contrib/solver/solvers/asl_sol_reader.py`) maps `100..=199` to
764/// `TerminationCondition.error`, so a correct square-problem solve
765/// reached the caller as a *solver error*; the legacy reader
766/// (`pyomo/opt/plugins/sol.py`) maps it to `optimal` + `status=warning`,
767/// the same gh #591 warning `SolvedToAcceptableLevel` was moved out of the
768/// band to escape. Ipopt on the identical solve loads clean in both. This
769/// is what gh #815 surfaced: an IDAES square flowsheet that POUNCE solves
770/// to a constraint violation of 2.2e-06 was reported as a failure.
771///
772/// This crate is not the only place that had to agree. `python/pounce/gams/link.py`
773/// already mapped the status to `(MODELSTAT_FEASIBLE, SOLVESTAT_NORMAL)`
774/// and listed it as a success, and
775/// `crates/pounce-algorithm/tests/issue_390_nonlinear_equality_scale.rs`
776/// already called it "a success-band answer — AMPL `objno` code 2, which
777/// every band table reads as SOLVED". The two Pyomo tables
778/// (`pyomo_pounce.v2._V2_STATUS`, `pyomo_pounce.sens._STATUS_RESULT`) were
779/// the dissenters and moved with this change.
780///
781/// Being in the solved band is *not* a claim that any feasible point is
782/// acceptable. `issue_390_nonlinear_equality_scale.rs` is the guard that a
783/// model with no solution is never reported feasible at any row scale; the
784/// relative-violation threshold at `resto_inner_solver.rs` is what keeps
785/// that true. This mapping decides how a verdict is reported, not when it
786/// is reached.
787pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
788    use ApplicationReturnStatus::*;
789    match status {
790        SolveSucceeded => 0,
791        SolvedToAcceptableLevel => 1,
792        FeasiblePointFound => 2,
793        InfeasibleProblemDetected => 200,
794        DivergingIterates => 300,
795        SearchDirectionBecomesTooSmall => 400,
796        MaximumIterationsExceeded => 400,
797        MaximumCpuTimeExceeded => 400,
798        MaximumWallTimeExceeded => 400,
799        UserRequestedStop => 502,
800        RestorationFailed => 500,
801        ErrorInStepComputation => 500,
802        InvalidNumberDetected => 500,
803        InternalError => 500,
804        UnrecoverableException => 500,
805        NonIpoptExceptionThrown => 500,
806        InsufficientMemory => 503,
807        InvalidProblemDefinition => 504,
808        InvalidOption => 504,
809        NotEnoughDegreesOfFreedom => 504,
810    }
811}
812
813/// Write a [`SolveReport`] to `path` as pretty-printed JSON. Returns
814/// bytes written on success.
815pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
816    let s = serde_json::to_string_pretty(report)
817        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
818    std::fs::write(path, &s)?;
819    Ok(s.len())
820}
821
822/// Convert Unix nanoseconds since the epoch to an ISO-8601 UTC
823/// timestamp `YYYY-MM-DDTHH:MM:SS.sssZ`. Pure stdlib; no chrono /
824/// time dependency. The conversion is based on the proleptic
825/// Gregorian calendar formula from Howard Hinnant's "date" reference
826/// (https://howardhinnant.github.io/date_algorithms.html), `days_from_civil`
827/// in reverse — verified against `date -u -r <secs>` for several
828/// epochs on 2026-05-14.
829fn unix_nanos_to_iso(nanos: i128) -> String {
830    let total_secs = nanos.div_euclid(1_000_000_000) as i64;
831    let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
832    let millis = frac_nanos / 1_000_000;
833
834    let days = total_secs.div_euclid(86_400);
835    let secs_of_day = total_secs.rem_euclid(86_400);
836    let hh = (secs_of_day / 3600) as i32;
837    let mm = ((secs_of_day % 3600) / 60) as i32;
838    let ss = (secs_of_day % 60) as i32;
839
840    // Howard Hinnant's `civil_from_days` algorithm:
841    //   z = days + 719468
842    //   era = (z >= 0 ? z : z - 146096) / 146097
843    //   doe = z - era*146097
844    //   yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365
845    //   y = yoe + era*400
846    //   doy = doe - (365*yoe + yoe/4 - yoe/100)
847    //   mp = (5*doy + 2) / 153
848    //   d = doy - (153*mp + 2)/5 + 1
849    //   m = mp < 10 ? mp + 3 : mp - 9
850    //   y += (m <= 2)
851    let z: i64 = days + 719468;
852    let era = if z >= 0 { z } else { z - 146096 } / 146097;
853    let doe = (z - era * 146097) as i64; // [0, 146096]
854    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
855    let mut y = yoe + era * 400;
856    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
857    let mp = (5 * doy + 2) / 153; // [0, 11]
858    let d = (doy - (153 * mp + 2) / 5 + 1) as i32;
859    let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
860    if m <= 2 {
861        y += 1;
862    }
863
864    format!(
865        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
866        y, m, d, hh, mm, ss, millis
867    )
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn iso_formatter_matches_known_epochs() {
876        // Epoch.
877        assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
878        // 2000-01-01T00:00:00Z = 946684800 seconds.
879        assert_eq!(
880            unix_nanos_to_iso(946_684_800_000_000_000),
881            "2000-01-01T00:00:00.000Z",
882        );
883        // 2024-02-29T12:34:56.789Z (leap-year sanity check).
884        // Seconds: (2024 - 1970) * 365.25 days * 86400 ≈ 1709209296 — let's compute exactly.
885        // Days from 1970-01-01 to 2024-02-29: 19782.
886        // 19782 * 86400 = 1709164800. Plus 12*3600 + 34*60 + 56 = 45296.
887        // Total = 1709210096.
888        let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
889        assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
890    }
891
892    #[test]
893    fn target_triple_resolves_to_real_triple_not_unknown() {
894        // Fail-first: before the build.rs re-export this constant read
895        // `option_env!("TARGET")`, which is `None` at crate-source compile
896        // time (Cargo only exposes TARGET to build scripts), so it was always
897        // "unknown". The build.rs now re-exports TARGET as
898        // POUNCE_TARGET_TRIPLE, which resolves it to the real build triple.
899        assert_ne!(
900            TARGET_TRIPLE, "unknown",
901            "build.rs should re-export the build target triple"
902        );
903        // A real triple has the `arch-vendor-os[-abi]` shape (>= 2 dashes).
904        assert!(
905            TARGET_TRIPLE.matches('-').count() >= 2,
906            "unexpected target triple: {TARGET_TRIPLE:?}"
907        );
908
909        // And it must propagate into the finished report.
910        let b = ReportBuilder::new(
911            ReportDetail::Summary,
912            InputDescriptor::NlFile {
913                path: PathBuf::from("/tmp/foo.nl"),
914                size_bytes: None,
915            },
916        );
917        let report = b.finish();
918        assert_eq!(report.fair_metadata.solver.target_triple, TARGET_TRIPLE);
919        assert_ne!(report.fair_metadata.solver.target_triple, "unknown");
920    }
921
922    #[test]
923    fn report_serializes_round_trip() {
924        let mut b = ReportBuilder::new(
925            ReportDetail::Summary,
926            InputDescriptor::NlFile {
927                path: PathBuf::from("/tmp/foo.nl"),
928                size_bytes: Some(123),
929            },
930        );
931        b.problem.n_variables = 5;
932        b.problem.n_constraints = 4;
933        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
934        b.solution.solve_result_num = 0;
935        b.solution.objective = 0.55;
936        b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
937        b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
938        b.stats.iteration_count = 9;
939
940        let report = b.finish();
941        let json = serde_json::to_string_pretty(&report).expect("serialize");
942        let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
943        assert_eq!(back.schema, "pounce.solve-report/v1");
944        assert_eq!(back.problem.n_variables, 5);
945        assert_eq!(back.solution.x.len(), 5);
946        assert!(matches!(
947            back.solution.status,
948            ApplicationReturnStatus::SolveSucceeded,
949        ));
950    }
951
952    /// gh #767: the report is the FAIR-aligned machine surface, and a
953    /// consumer keyed on Ipopt's own enumerator spelling — which is what
954    /// CUTEst tables, the reference JSONs and the CLI's `Status:` line all
955    /// use — must be able to compare a field literally. `status` carries
956    /// the Rust variant name (`SolveSucceeded`); `status_upstream` carries
957    /// `Solve_Succeeded`. A consumer that compared the former against the
958    /// latter's spelling matched nothing and read every solve as a failure.
959    #[test]
960    fn report_carries_the_upstream_status_spelling_beside_the_rust_one() {
961        let mut b = ReportBuilder::new(
962            ReportDetail::Summary,
963            InputDescriptor::Builtin {
964                name: "rosenbrock".into(),
965            },
966        );
967        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
968        let json = serde_json::to_value(b.finish()).expect("serialize");
969        assert_eq!(json["solution"]["status"], "SolveSucceeded");
970        assert_eq!(json["solution"]["status_upstream"], "Solve_Succeeded");
971    }
972
973    /// The derived field tracks whatever `status` was last set to — it is
974    /// computed in `finish`, so a caller cannot leave it stale or set the
975    /// two to different verdicts.
976    #[test]
977    fn upstream_status_spelling_is_derived_not_stored() {
978        for status in [
979            ApplicationReturnStatus::MaximumIterationsExceeded,
980            ApplicationReturnStatus::InfeasibleProblemDetected,
981            ApplicationReturnStatus::SolvedToAcceptableLevel,
982        ] {
983            let mut b = ReportBuilder::new(
984                ReportDetail::Summary,
985                InputDescriptor::Builtin { name: "x".into() },
986            );
987            // Deliberately wrong; `finish` must overwrite it.
988            b.solution.status_upstream = "Solve_Succeeded".to_string();
989            b.solution.status = status;
990            let report = b.finish();
991            assert_eq!(report.solution.status_upstream, status.upstream_name());
992        }
993    }
994
995    #[test]
996    fn summary_detail_omits_iterations_block() {
997        let mut b = ReportBuilder::new(
998            ReportDetail::Summary,
999            InputDescriptor::Builtin {
1000                name: "rosenbrock".into(),
1001            },
1002        );
1003        let mut stats = SolveStatistics::default();
1004        stats.iterations.push(IterRecord {
1005            iter: 0,
1006            objective: 1.0,
1007            ..IterRecord::default()
1008        });
1009        b.ingest_stats(&stats);
1010        let r = b.finish();
1011        assert!(
1012            r.iterations.is_empty(),
1013            "Summary detail should drop iter history; got {} rows",
1014            r.iterations.len()
1015        );
1016        // And the JSON should not include the key at all (skip-empty).
1017        let json = serde_json::to_string(&r).unwrap();
1018        assert!(!json.contains("\"iterations\":"), "json: {json}");
1019    }
1020
1021    #[test]
1022    fn full_detail_includes_iteration_rows() {
1023        let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
1024        let mut stats = SolveStatistics::default();
1025        stats.iterations.push(IterRecord {
1026            iter: 0,
1027            objective: 1.0,
1028            inf_pr: 0.5,
1029            ..IterRecord::default()
1030        });
1031        stats.iterations.push(IterRecord {
1032            iter: 1,
1033            objective: 0.5,
1034            inf_pr: 0.1,
1035            ..IterRecord::default()
1036        });
1037        b.ingest_stats(&stats);
1038        let r = b.finish();
1039        assert_eq!(r.iterations.len(), 2);
1040        assert_eq!(r.iterations[0].iter, 0);
1041        assert_eq!(r.iterations[1].iter, 1);
1042    }
1043
1044    #[test]
1045    fn detail_parser_accepts_known_values() {
1046        assert_eq!(
1047            ReportDetail::parse("summary").unwrap(),
1048            ReportDetail::Summary
1049        );
1050        assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
1051        assert!(ReportDetail::parse("verbose").is_err());
1052    }
1053
1054    #[test]
1055    fn diverging_iterates_maps_to_unbounded_range() {
1056        use ApplicationReturnStatus::*;
1057        // M12 regression: DivergingIterates is Ipopt's unboundedness
1058        // signal and must land in the AMPL 300 "unbounded" range, not
1059        // the 400 "limit" range — matching upstream Ipopt's ASL driver
1060        // and the CLI convex path (QpStatus::DualInfeasible → 300).
1061        assert_eq!(status_to_solve_result_num(DivergingIterates), 300);
1062
1063        // Lock the surrounding range convention so the fix can't silently
1064        // drift back: solved / infeasible / limit / failure buckets.
1065        assert_eq!(status_to_solve_result_num(SolveSucceeded), 0);
1066        assert_eq!(status_to_solve_result_num(InfeasibleProblemDetected), 200);
1067        assert_eq!(
1068            status_to_solve_result_num(MaximumIterationsExceeded),
1069            400,
1070            "iteration limit stays in the 400 range",
1071        );
1072        assert_eq!(
1073            status_to_solve_result_num(SearchDirectionBecomesTooSmall),
1074            400,
1075        );
1076        assert_eq!(status_to_solve_result_num(RestorationFailed), 500);
1077    }
1078
1079    /// gh #591: an accepted (reduced-accuracy) solve must land in AMPL's
1080    /// `0..=99` *solved* band with Ipopt's own code, `1`. In the 100 band
1081    /// Pyomo's legacy `.sol` reader loads the result as
1082    /// `status=warning, termination_condition=optimal` and logs a warning,
1083    /// while the identical Ipopt solve loads as `status=ok` — so a
1084    /// solver-swappable client that treats `status == ok` as part of its
1085    /// accepted-solve contract had to special-case POUNCE.
1086    #[test]
1087    fn solved_to_acceptable_level_is_in_the_solved_band_like_ipopt() {
1088        use ApplicationReturnStatus::*;
1089        let code = status_to_solve_result_num(SolvedToAcceptableLevel);
1090        assert_eq!(
1091            code, 1,
1092            "Ipopt's ASL driver emits 1 for STOP_AT_ACCEPTABLE_POINT",
1093        );
1094        assert!(
1095            (0..=99).contains(&code),
1096            "must be in the solved band Pyomo maps to status=ok, got {code}",
1097        );
1098        // Still distinguishable from a full-accuracy solve: the two codes
1099        // differ, and the status name carries the distinction verbatim into
1100        // the `.sol` message line.
1101        assert_ne!(code, status_to_solve_result_num(SolveSucceeded));
1102
1103        // `FeasiblePointFound` is also in the solved band, for its own
1104        // reason — see `a_square_problem_feasible_point_is_in_the_solved_band`.
1105        assert_ne!(code, status_to_solve_result_num(FeasiblePointFound));
1106    }
1107
1108    /// POUNCE emits `FeasiblePointFound` only for square problems — the
1109    /// status has one production site (`min_c_1nrm.rs`) behind one gate
1110    /// (`resto_inner_solver.rs`), and that gate is `is_square_problem &&
1111    /// ...`. That is exactly Ipopt's meaning, and on a square problem a
1112    /// feasible point *is* the solution, so the code is Ipopt's own `2`.
1113    ///
1114    /// The band, not the number, is what breaks: at `100` Pyomo's v2 ASL
1115    /// reader returns `TerminationCondition.error` for a correct solve
1116    /// (gh #815 — an IDAES flowsheet solved to a 2.2e-06 constraint
1117    /// violation and reported as a solver error), and the legacy reader
1118    /// returns `status=warning`, the same gh #591 complaint that moved
1119    /// `SolvedToAcceptableLevel` out of the band.
1120    #[test]
1121    fn a_square_problem_feasible_point_is_in_the_solved_band() {
1122        use ApplicationReturnStatus::*;
1123        let code = status_to_solve_result_num(FeasiblePointFound);
1124        assert_eq!(
1125            code, 2,
1126            "Ipopt's ASL driver emits 2 for FEASIBLE_POINT_FOUND"
1127        );
1128        assert!(
1129            (0..=99).contains(&code),
1130            "must be in the solved band both Pyomo readers accept, got {code}",
1131        );
1132        // Still its own verdict: distinguishable from both other members of
1133        // the band, with the distinction carried verbatim in the status name
1134        // and the `.sol` message line.
1135        assert_ne!(code, status_to_solve_result_num(SolveSucceeded));
1136        assert_ne!(code, status_to_solve_result_num(SolvedToAcceptableLevel));
1137    }
1138
1139    #[test]
1140    fn result_id_is_unique_and_time_ordered() {
1141        let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
1142        std::thread::sleep(std::time::Duration::from_millis(2));
1143        let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
1144        assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
1145        assert!(
1146            b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
1147            "second result_id should sort after first"
1148        );
1149    }
1150}