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