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