Skip to main content

pounce_solve_report/
lib.rs

1//! Machine-readable JSON solve report (pounce#8).
2//!
3//! Bundles the same payload AMPL's `.sol` carries (status, primal,
4//! dual, suffixes) with FAIR-aligned provenance metadata (solver
5//! identity, input descriptor, timestamp) and per-iteration history
6//! when requested. Schema is versioned via the top-level `schema`
7//! field so future extensions don't silently change semantics.
8//!
9//! FAIR reference: Wilkinson et al. (2016). *The FAIR Guiding
10//! Principles for scientific data management and stewardship.*
11//! Scientific Data, 3, 160018. DOI:
12//! [10.1038/sdata.2016.18](https://doi.org/10.1038/sdata.2016.18).
13//! Verified via Crossref on 2026-05-14.
14//!
15//! # Schema versioning
16//!
17//! The current schema tag is `pounce.solve-report/v1`. Breaking
18//! changes bump the major version (v2 etc.). Adding fields without
19//! removing or renaming existing ones is non-breaking — JSON
20//! consumers should tolerate unknown fields.
21//!
22//! # Detail levels
23//!
24//! [`ReportDetail::Summary`] (default) emits the FAIR metadata,
25//! problem dimensions, final solution, and aggregate statistics
26//! — equivalent to a `.sol` plus provenance. [`ReportDetail::Full`]
27//! additionally emits the per-iteration history (when captured by
28//! [`pounce_algorithm::application::IpoptApplication::enable_iter_history`])
29//! and any `solution.suffixes`. Choose `Summary` for production logs
30//! and `Full` for debug captures.
31
32use std::path::{Path, PathBuf};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use pounce_common::types::{Index, Number};
36use pounce_linsol::summary::LinearSolverSummary;
37use pounce_nlp::return_codes::ApplicationReturnStatus;
38use pounce_nlp::solve_statistics::{IterRecord, SolveStatistics};
39use serde::{Deserialize, Serialize};
40
41/// Ipopt-style console printers (banner, problem statistics, end-of-run
42/// summary). The single source of truth for the text log: the algorithm's
43/// output layer emits these gated on `print_level`, and the CLI reuses the
44/// banner. Moved out of `pounce-cli` so `pounce-algorithm` can emit them
45/// natively (#206).
46pub mod console;
47
48/// Verbosity knob for the JSON report. Maps onto the `--json-detail`
49/// CLI flag.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ReportDetail {
52    /// FAIR metadata, problem, solution scalars + arrays, aggregate
53    /// stats. Per-iteration history and suffix blocks omitted.
54    Summary,
55    /// Everything in `Summary` plus per-iteration history and any
56    /// suffix outputs (`sens_sol_state_1`, reduced-Hessian blocks).
57    Full,
58}
59
60impl ReportDetail {
61    pub fn parse(s: &str) -> Result<Self, String> {
62        match s.to_ascii_lowercase().as_str() {
63            "summary" => Ok(ReportDetail::Summary),
64            "full" => Ok(ReportDetail::Full),
65            other => Err(format!(
66                "unknown --json-detail '{other}' (expected: summary | full)"
67            )),
68        }
69    }
70}
71
72/// Top-level report struct. Fields are ordered so the JSON has the
73/// most identifying / metadata fields first when pretty-printed.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SolveReport {
76    /// Schema identifier. Always
77    /// `"pounce.solve-report/v1"` for this version of the writer.
78    pub schema: String,
79    /// FAIR provenance metadata.
80    pub fair_metadata: FairMetadata,
81    /// Problem dimensions and shape.
82    pub problem: ProblemInfo,
83    /// Final solution payload (status, primal, dual, suffixes).
84    pub solution: SolutionInfo,
85    /// Aggregate statistics (eval counts, KKT residuals, timing).
86    pub statistics: StatisticsInfo,
87    /// Per-iteration history. Empty when the report is at
88    /// [`ReportDetail::Summary`] or iter history was never enabled.
89    #[serde(skip_serializing_if = "Vec::is_empty", default)]
90    pub iterations: Vec<IterRecord>,
91    /// Aggregate linear-solver post-mortem. Populated when the
92    /// workspace-default FERAL backend ran (it self-instruments via
93    /// `feral::Solver::last_factor_stats()`); `None` for HSL MA57 and
94    /// for custom backends plugged through
95    /// [`pounce_algorithm::application::IpoptApplication::set_linear_backend_factory`].
96    /// Additive — older `pounce.solve-report/v1` JSON without this
97    /// field deserializes unchanged.
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    pub linear_solver: Option<LinearSolverSummaryInfo>,
100}
101
102/// Serializable mirror of [`pounce_linsol::summary::LinearSolverSummary`].
103/// Lives in the CLI crate (rather than `pounce-linsol`) so the linsol
104/// trait crate stays serde-free. Field shape is identical; serde
105/// defaults keep it forward-compatible with future additions.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LinearSolverSummaryInfo {
108    pub solver_name: String,
109    pub n_factors: u64,
110    pub n_pattern_reuse: u64,
111    pub n_pattern_changes: u64,
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub max_fill_ratio: Option<f64>,
114    #[serde(skip_serializing_if = "Option::is_none", default)]
115    pub min_abs_pivot: Option<f64>,
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub max_abs_pivot: Option<f64>,
118    /// `(positive, negative, zero)` inertia of the final factorisation.
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub last_inertia: Option<(usize, usize, usize)>,
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub last_nnz_a: Option<usize>,
123    #[serde(skip_serializing_if = "Option::is_none", default)]
124    pub last_nnz_l: Option<usize>,
125}
126
127impl From<LinearSolverSummary> for LinearSolverSummaryInfo {
128    fn from(s: LinearSolverSummary) -> Self {
129        Self {
130            solver_name: s.solver_name,
131            n_factors: s.n_factors,
132            n_pattern_reuse: s.n_pattern_reuse,
133            n_pattern_changes: s.n_pattern_changes,
134            max_fill_ratio: s.max_fill_ratio,
135            min_abs_pivot: s.min_abs_pivot,
136            max_abs_pivot: s.max_abs_pivot,
137            last_inertia: s.last_inertia,
138            last_nnz_a: s.last_nnz_a,
139            last_nnz_l: s.last_nnz_l,
140        }
141    }
142}
143
144/// FAIR-aligned provenance block. The four FAIR principles
145/// (Wilkinson et al., 2016) map onto fields here as:
146/// * **F**indable: `result_id` (unique per solve), `created_at_iso`.
147/// * **A**ccessible: this JSON file is the artifact — no protocol
148///   gating, plain text on disk.
149/// * **I**nteroperable: schema versioned, types are JSON primitives,
150///   units documented in field doc comments.
151/// * **R**eusable: `solver`, `license`, `input` describe what was
152///   solved with what code, enough to reproduce.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct FairMetadata {
155    /// Unique per-solve identifier. Composed as
156    /// `<unix_nanos>-<process_id>` so it is monotonically ordered
157    /// within a process and globally unique across processes.
158    pub result_id: String,
159    /// Solve start time as ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SS.sssZ`).
160    pub created_at_iso: String,
161    /// Same instant in Unix nanoseconds (since 1970-01-01 UTC).
162    /// Provided alongside the ISO string for callers that prefer
163    /// integer arithmetic over date parsing.
164    pub created_at_unix_nanos: i128,
165    /// Wallclock seconds the solve took. Mirrors
166    /// [`SolveStatistics::total_wallclock_time_secs`].
167    pub elapsed_seconds: Number,
168    /// Solver identity — name + version + (best-effort) git commit.
169    pub solver: SolverIdentity,
170    /// SPDX license string. Always `"EPL-2.0"` for this crate.
171    pub license: String,
172    /// Input descriptor. `kind` is `nl-file`, `builtin`, or
173    /// `tnlp-direct` (for library callers).
174    pub input: InputDescriptor,
175    /// Solve-affecting environment variables present in the process
176    /// environment at report time (`POUNCE_FERAL_*` numerics knobs and
177    /// the legacy `FERAL_PIVTOL` / `FERAL_PARALLEL`). These alter the
178    /// factorization or parallelism and can otherwise silently differ a
179    /// run between two machines — e.g. one with `POUNCE_FERAL_PIVTOL`
180    /// exported in a shell profile — with nothing in the report saying
181    /// so (pounce#235). Recorded for reproducibility (the FAIR **R**
182    /// principle). Presence here means the variable was set, not
183    /// necessarily that it took effect: an explicit `OptionsList` setting
184    /// (e.g. a `feral_pivtol` in an options file) takes precedence over
185    /// the env fallback. Empty (and omitted from the JSON) when none are
186    /// set. Additive — older `pounce.solve-report/v1` JSON without this
187    /// field deserializes unchanged.
188    #[serde(skip_serializing_if = "Vec::is_empty", default)]
189    pub environment: Vec<EnvOverride>,
190}
191
192/// One solve-affecting environment variable and its value, as captured
193/// into [`FairMetadata::environment`].
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct EnvOverride {
196    /// Variable name, e.g. `POUNCE_FERAL_PIVTOL`.
197    pub name: String,
198    /// Its value verbatim, as read from the environment.
199    pub value: String,
200}
201
202/// Environment variables that change pounce's numerics or parallelism —
203/// the ones worth recording for reproducibility. Deliberately excludes the
204/// `POUNCE_DBG_*` debug gates (they only add diagnostic output, never
205/// altering the result) and compile-time / logging vars. Kept in a fixed
206/// order so [`capture_solve_env_overrides`] is deterministic.
207const SOLVE_AFFECTING_ENV_VARS: &[&str] = &[
208    "POUNCE_FERAL_ORDERING",
209    "POUNCE_FERAL_SCALING",
210    "POUNCE_FERAL_PIVTOL",
211    "POUNCE_FERAL_REFINE",
212    "POUNCE_FERAL_CASCADE_BREAK",
213    "POUNCE_FERAL_FMA",
214    "POUNCE_FERAL_SINGULAR_PIVOT_FLOOR",
215    "POUNCE_FERAL_MIN_PAR_FLOPS",
216    // Legacy aliases without the `POUNCE_` prefix (still honored by the
217    // FERAL backend): the deprecated pivot-threshold spelling and the
218    // process-wide internal-parallelism switch.
219    "FERAL_PIVTOL",
220    "FERAL_PARALLEL",
221];
222
223/// Snapshot the solve-affecting environment variables ([`SOLVE_AFFECTING_ENV_VARS`])
224/// that are currently set, for [`FairMetadata::environment`]. Reads the
225/// process environment, so call it in the solving process. Returns them in
226/// the fixed list order; absent variables are simply skipped.
227pub fn capture_solve_env_overrides() -> Vec<EnvOverride> {
228    SOLVE_AFFECTING_ENV_VARS
229        .iter()
230        .filter_map(|&name| {
231            std::env::var(name).ok().map(|value| EnvOverride {
232                name: name.to_string(),
233                value,
234            })
235        })
236        .collect()
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SolverIdentity {
241    pub name: String,
242    pub version: String,
243    /// Git commit hash, captured at build time from the
244    /// `POUNCE_GIT_COMMIT` environment variable. `None` if the build
245    /// environment didn't set it — set via
246    /// `POUNCE_GIT_COMMIT=$(git rev-parse HEAD) cargo build`.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub git_commit: Option<String>,
249    /// Build target triple (e.g. `x86_64-apple-darwin`). Captured at
250    /// build time from `TARGET` (Cargo standard env var).
251    pub target_triple: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "kind", rename_all = "kebab-case")]
256pub enum InputDescriptor {
257    NlFile {
258        path: PathBuf,
259        #[serde(skip_serializing_if = "Option::is_none")]
260        size_bytes: Option<u64>,
261    },
262    /// A Conic Benchmark Format (`.cbf`) instance — e.g. a CBLIB problem
263    /// solved through the convex conic driver.
264    CbfFile {
265        path: PathBuf,
266        #[serde(skip_serializing_if = "Option::is_none")]
267        size_bytes: Option<u64>,
268    },
269    Builtin {
270        name: String,
271    },
272    TnlpDirect,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ProblemInfo {
277    pub n_variables: Index,
278    pub n_constraints: Index,
279    pub n_objectives: Index,
280    pub minimize: bool,
281    /// Number of non-zeros declared by the TNLP for the constraint
282    /// Jacobian. `None` if not exposed by the input path.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub nnz_jac_g: Option<Index>,
285    /// Number of non-zeros declared for the Lagrangian Hessian.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub nnz_h_lag: Option<Index>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SolutionInfo {
292    /// `SolveSucceeded`, `MaximumIterationsExceeded`, etc. The string
293    /// form is the Rust enum variant name verbatim.
294    pub status: ApplicationReturnStatus,
295    /// AMPL-style solve-result code (Gay 2005, §5 p. 23 table).
296    pub solve_result_num: i32,
297    /// Final unscaled objective value (mirrors
298    /// `SolveStatistics::final_objective`). `NaN` if unknown.
299    pub objective: Number,
300    /// Final primal vector, length `problem.n_variables`. Empty if
301    /// not captured.
302    #[serde(skip_serializing_if = "Vec::is_empty", default)]
303    pub x: Vec<Number>,
304    /// Final dual (constraint multiplier) vector, length
305    /// `problem.n_constraints`.
306    #[serde(skip_serializing_if = "Vec::is_empty", default)]
307    pub lambda: Vec<Number>,
308    /// Optional sIPOPT-style suffix blocks (`sens_sol_state_1` etc.).
309    /// Stored as a flat map keyed by suffix name → list of
310    /// `(index, value)` pairs, matching the AMPL `.sol` shape.
311    /// Empty when no sensitivity / reduced-Hessian step ran.
312    #[serde(skip_serializing_if = "Vec::is_empty", default)]
313    pub suffixes: Vec<SolutionSuffix>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct SolutionSuffix {
318    pub name: String,
319    /// `"var" | "con" | "obj" | "problem"` per AMPL convention.
320    pub target: String,
321    /// `"int"` or `"real"`.
322    pub kind: String,
323    /// Dense values (length = target dimension); zero-filled for
324    /// slots the writer didn't populate. Real-typed values are stored
325    /// here; int-typed in `int_values`.
326    #[serde(skip_serializing_if = "Vec::is_empty", default)]
327    pub values: Vec<Number>,
328    #[serde(skip_serializing_if = "Vec::is_empty", default)]
329    pub int_values: Vec<Index>,
330}
331
332/// NaN, for a residual slot that was never filled in.
333fn uncomputed() -> Number {
334    Number::NAN
335}
336
337/// Accept `null` for a residual the solve never computed.
338///
339/// `SolveStatistics` defaults its residual fields to NaN rather than `0.0`, so
340/// that "the convergence check never ran" is distinguishable from "converged
341/// exactly". `serde_json` renders a non-finite float as `null`, so a report
342/// written for a solve that was refused during setup carries `null` in these
343/// slots. Without this the report round-trip fails — pounce would write
344/// reports its own `--cite` / studio / verify paths could not read back.
345fn null_as_nan<'de, D>(de: D) -> Result<Number, D::Error>
346where
347    D: serde::Deserializer<'de>,
348{
349    Ok(Option::<Number>::deserialize(de)?.unwrap_or_else(uncomputed))
350}
351
352/// Subset of `SolveStatistics` projected for the report. Mirrors the
353/// fields the existing console summary prints.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct StatisticsInfo {
356    pub iteration_count: Index,
357    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
358    pub final_objective: Number,
359    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
360    pub final_scaled_objective: Number,
361    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
362    pub final_dual_inf: Number,
363    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
364    pub final_constr_viol: Number,
365    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
366    pub final_compl: Number,
367    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
368    pub final_kkt_error: Number,
369    /// The aggregate the strict convergence gate tested (gh #528): as
370    /// `final_kkt_error`, but counting each constraint row's residual only
371    /// where it exceeds what that row can represent in floating point. Equal
372    /// to `final_kkt_error` unless a row is at its own resolution limit.
373    #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
374    pub final_kkt_error_above_noise: Number,
375    pub num_obj_evals: Index,
376    pub num_constr_evals: Index,
377    pub num_obj_grad_evals: Index,
378    pub num_constr_jac_evals: Index,
379    pub num_hess_evals: Index,
380    pub total_wallclock_time_secs: Number,
381    pub restoration_calls: Index,
382    pub restoration_inner_iters: Index,
383    pub restoration_outer_iters: Index,
384    pub restoration_wall_secs: Number,
385}
386
387/// Builder collecting the inputs for a [`SolveReport`]. The CLI
388/// drivers populate one of these as they walk through the solve and
389/// `finish()` it at the end.
390pub struct ReportBuilder {
391    detail: ReportDetail,
392    started_at: SystemTime,
393    started_unix_nanos: i128,
394    pub input: InputDescriptor,
395    pub problem: ProblemInfo,
396    pub solution: SolutionInfo,
397    pub stats: StatisticsInfo,
398    pub iterations: Vec<IterRecord>,
399    pub linear_solver: Option<LinearSolverSummaryInfo>,
400}
401
402impl ReportBuilder {
403    pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
404        let now = SystemTime::now();
405        let nanos = now
406            .duration_since(UNIX_EPOCH)
407            .map(|d| d.as_nanos() as i128)
408            .unwrap_or(0);
409        Self {
410            detail,
411            started_at: now,
412            started_unix_nanos: nanos,
413            input,
414            problem: ProblemInfo {
415                n_variables: 0,
416                n_constraints: 0,
417                n_objectives: 0,
418                minimize: true,
419                nnz_jac_g: None,
420                nnz_h_lag: None,
421            },
422            solution: SolutionInfo {
423                status: ApplicationReturnStatus::InternalError,
424                solve_result_num: 500,
425                // 0.0 (not NaN) so JSON round-trips. Callers that
426                // need "unknown objective" semantics check
427                // `statistics.iteration_count > 0` first.
428                objective: 0.0,
429                x: Vec::new(),
430                lambda: Vec::new(),
431                suffixes: Vec::new(),
432            },
433            stats: empty_stats(),
434            iterations: Vec::new(),
435            linear_solver: None,
436        }
437    }
438
439    /// Attach a linear-solver post-mortem. Called once per solve after
440    /// `optimize_tnlp` returns and before [`Self::finish`].
441    pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
442        self.linear_solver = Some(summary.into());
443    }
444
445    /// Pull `iteration_count`, `final_*`, and counters into the
446    /// `stats` slot; copy `iterations` only if detail = Full.
447    pub fn ingest_stats(&mut self, src: &SolveStatistics) {
448        self.stats = StatisticsInfo {
449            iteration_count: src.iteration_count,
450            final_objective: src.final_objective,
451            final_scaled_objective: src.final_scaled_objective,
452            final_dual_inf: src.final_dual_inf,
453            final_constr_viol: src.final_constr_viol,
454            final_compl: src.final_compl,
455            final_kkt_error: src.final_kkt_error,
456            final_kkt_error_above_noise: src.final_kkt_error_above_noise,
457            num_obj_evals: src.num_obj_evals,
458            num_constr_evals: src.num_constr_evals,
459            num_obj_grad_evals: src.num_obj_grad_evals,
460            num_constr_jac_evals: src.num_constr_jac_evals,
461            num_hess_evals: src.num_hess_evals,
462            total_wallclock_time_secs: src.total_wallclock_time_secs,
463            restoration_calls: src.restoration_calls,
464            restoration_inner_iters: src.restoration_inner_iters,
465            restoration_outer_iters: src.restoration_outer_iters,
466            restoration_wall_secs: src.restoration_wall_secs,
467        };
468        if matches!(self.detail, ReportDetail::Full) {
469            self.iterations = src.iterations.clone();
470        }
471    }
472
473    pub fn finish(self) -> SolveReport {
474        let elapsed = self
475            .started_at
476            .elapsed()
477            .map(|d| d.as_secs_f64())
478            .unwrap_or(0.0);
479        let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
480        let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
481
482        SolveReport {
483            schema: "pounce.solve-report/v1".to_string(),
484            fair_metadata: FairMetadata {
485                result_id,
486                created_at_iso,
487                created_at_unix_nanos: self.started_unix_nanos,
488                elapsed_seconds: elapsed,
489                solver: SolverIdentity {
490                    name: "pounce".to_string(),
491                    version: env!("CARGO_PKG_VERSION").to_string(),
492                    git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
493                    target_triple: TARGET_TRIPLE.to_string(),
494                },
495                license: "EPL-2.0".to_string(),
496                input: self.input,
497                environment: capture_solve_env_overrides(),
498            },
499            problem: self.problem,
500            solution: self.solution,
501            statistics: self.stats,
502            iterations: self.iterations,
503            linear_solver: self.linear_solver,
504        }
505    }
506}
507
508/// The build target triple (e.g. `aarch64-apple-darwin`).
509///
510/// Cargo only exposes `TARGET` to *build scripts*, not to crate source, so
511/// `option_env!("TARGET")` here is always `None`. Our `build.rs` re-exports
512/// the build script's `TARGET` as `POUNCE_TARGET_TRIPLE`, which we read
513/// instead. Falls back to "unknown" if the build script did not run (e.g.
514/// some non-Cargo tooling).
515const TARGET_TRIPLE: &str = match option_env!("POUNCE_TARGET_TRIPLE") {
516    Some(t) => t,
517    None => "unknown",
518};
519
520fn empty_stats() -> StatisticsInfo {
521    // All scalar fields start at 0.0 (not NaN) so the report
522    // round-trips through `serde_json` — JSON has no NaN literal, and
523    // serde_json's default is to write `null` for NaN, which then
524    // fails to deserialize back into `Number`. Callers reading these
525    // pre-solve treat `iteration_count == 0` as "no solve yet".
526    StatisticsInfo {
527        iteration_count: 0,
528        final_objective: 0.0,
529        final_scaled_objective: 0.0,
530        final_dual_inf: 0.0,
531        final_constr_viol: 0.0,
532        final_compl: 0.0,
533        final_kkt_error: 0.0,
534        final_kkt_error_above_noise: 0.0,
535        num_obj_evals: 0,
536        num_constr_evals: 0,
537        num_obj_grad_evals: 0,
538        num_constr_jac_evals: 0,
539        num_hess_evals: 0,
540        total_wallclock_time_secs: 0.0,
541        restoration_calls: 0,
542        restoration_inner_iters: 0,
543        restoration_outer_iters: 0,
544        restoration_wall_secs: 0.0,
545    }
546}
547
548/// AMPL-style `solve_result_num` per Gay 2005 (Hooking Your Solver to
549/// AMPL §5, p. 23 table): 0 = solved, 100s = warning, 200s =
550/// infeasible, 300s = unbounded, 400s = limit reached, 500s = failure.
551/// Shared by the CLI and cinterface report writers so both encode the
552/// same int codes into `SolutionInfo::solve_result_num`.
553///
554/// `DivergingIterates` is Ipopt's unboundedness signal (the iterates run
555/// off to infinity), so it maps to the 300 "unbounded" range — matching
556/// upstream Ipopt's ASL driver and the CLI's own convex path, which
557/// reports `QpStatus::DualInfeasible` (unbounded) as 300 (`main.rs`). It
558/// is *not* a limit (400) condition.
559pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
560    use ApplicationReturnStatus::*;
561    match status {
562        SolveSucceeded => 0,
563        SolvedToAcceptableLevel => 100,
564        FeasiblePointFound => 100,
565        InfeasibleProblemDetected => 200,
566        DivergingIterates => 300,
567        SearchDirectionBecomesTooSmall => 400,
568        MaximumIterationsExceeded => 400,
569        MaximumCpuTimeExceeded => 400,
570        MaximumWallTimeExceeded => 400,
571        UserRequestedStop => 502,
572        RestorationFailed => 500,
573        ErrorInStepComputation => 500,
574        InvalidNumberDetected => 500,
575        InternalError => 500,
576        UnrecoverableException => 500,
577        NonIpoptExceptionThrown => 500,
578        InsufficientMemory => 503,
579        InvalidProblemDefinition => 504,
580        InvalidOption => 504,
581        NotEnoughDegreesOfFreedom => 504,
582    }
583}
584
585/// Write a [`SolveReport`] to `path` as pretty-printed JSON. Returns
586/// bytes written on success.
587pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
588    let s = serde_json::to_string_pretty(report)
589        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
590    std::fs::write(path, &s)?;
591    Ok(s.len())
592}
593
594/// Convert Unix nanoseconds since the epoch to an ISO-8601 UTC
595/// timestamp `YYYY-MM-DDTHH:MM:SS.sssZ`. Pure stdlib; no chrono /
596/// time dependency. The conversion is based on the proleptic
597/// Gregorian calendar formula from Howard Hinnant's "date" reference
598/// (https://howardhinnant.github.io/date_algorithms.html), `days_from_civil`
599/// in reverse — verified against `date -u -r <secs>` for several
600/// epochs on 2026-05-14.
601fn unix_nanos_to_iso(nanos: i128) -> String {
602    let total_secs = nanos.div_euclid(1_000_000_000) as i64;
603    let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
604    let millis = frac_nanos / 1_000_000;
605
606    let days = total_secs.div_euclid(86_400);
607    let secs_of_day = total_secs.rem_euclid(86_400);
608    let hh = (secs_of_day / 3600) as i32;
609    let mm = ((secs_of_day % 3600) / 60) as i32;
610    let ss = (secs_of_day % 60) as i32;
611
612    // Howard Hinnant's `civil_from_days` algorithm:
613    //   z = days + 719468
614    //   era = (z >= 0 ? z : z - 146096) / 146097
615    //   doe = z - era*146097
616    //   yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365
617    //   y = yoe + era*400
618    //   doy = doe - (365*yoe + yoe/4 - yoe/100)
619    //   mp = (5*doy + 2) / 153
620    //   d = doy - (153*mp + 2)/5 + 1
621    //   m = mp < 10 ? mp + 3 : mp - 9
622    //   y += (m <= 2)
623    let z: i64 = days + 719468;
624    let era = if z >= 0 { z } else { z - 146096 } / 146097;
625    let doe = (z - era * 146097) as i64; // [0, 146096]
626    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
627    let mut y = yoe + era * 400;
628    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
629    let mp = (5 * doy + 2) / 153; // [0, 11]
630    let d = (doy - (153 * mp + 2) / 5 + 1) as i32;
631    let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
632    if m <= 2 {
633        y += 1;
634    }
635
636    format!(
637        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
638        y, m, d, hh, mm, ss, millis
639    )
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn iso_formatter_matches_known_epochs() {
648        // Epoch.
649        assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
650        // 2000-01-01T00:00:00Z = 946684800 seconds.
651        assert_eq!(
652            unix_nanos_to_iso(946_684_800_000_000_000),
653            "2000-01-01T00:00:00.000Z",
654        );
655        // 2024-02-29T12:34:56.789Z (leap-year sanity check).
656        // Seconds: (2024 - 1970) * 365.25 days * 86400 ≈ 1709209296 — let's compute exactly.
657        // Days from 1970-01-01 to 2024-02-29: 19782.
658        // 19782 * 86400 = 1709164800. Plus 12*3600 + 34*60 + 56 = 45296.
659        // Total = 1709210096.
660        let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
661        assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
662    }
663
664    #[test]
665    fn target_triple_resolves_to_real_triple_not_unknown() {
666        // Fail-first: before the build.rs re-export this constant read
667        // `option_env!("TARGET")`, which is `None` at crate-source compile
668        // time (Cargo only exposes TARGET to build scripts), so it was always
669        // "unknown". The build.rs now re-exports TARGET as
670        // POUNCE_TARGET_TRIPLE, which resolves it to the real build triple.
671        assert_ne!(
672            TARGET_TRIPLE, "unknown",
673            "build.rs should re-export the build target triple"
674        );
675        // A real triple has the `arch-vendor-os[-abi]` shape (>= 2 dashes).
676        assert!(
677            TARGET_TRIPLE.matches('-').count() >= 2,
678            "unexpected target triple: {TARGET_TRIPLE:?}"
679        );
680
681        // And it must propagate into the finished report.
682        let b = ReportBuilder::new(
683            ReportDetail::Summary,
684            InputDescriptor::NlFile {
685                path: PathBuf::from("/tmp/foo.nl"),
686                size_bytes: None,
687            },
688        );
689        let report = b.finish();
690        assert_eq!(report.fair_metadata.solver.target_triple, TARGET_TRIPLE);
691        assert_ne!(report.fair_metadata.solver.target_triple, "unknown");
692    }
693
694    #[test]
695    fn report_serializes_round_trip() {
696        let mut b = ReportBuilder::new(
697            ReportDetail::Summary,
698            InputDescriptor::NlFile {
699                path: PathBuf::from("/tmp/foo.nl"),
700                size_bytes: Some(123),
701            },
702        );
703        b.problem.n_variables = 5;
704        b.problem.n_constraints = 4;
705        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
706        b.solution.solve_result_num = 0;
707        b.solution.objective = 0.55;
708        b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
709        b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
710        b.stats.iteration_count = 9;
711
712        let report = b.finish();
713        let json = serde_json::to_string_pretty(&report).expect("serialize");
714        let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
715        assert_eq!(back.schema, "pounce.solve-report/v1");
716        assert_eq!(back.problem.n_variables, 5);
717        assert_eq!(back.solution.x.len(), 5);
718        assert!(matches!(
719            back.solution.status,
720            ApplicationReturnStatus::SolveSucceeded,
721        ));
722    }
723
724    #[test]
725    fn summary_detail_omits_iterations_block() {
726        let mut b = ReportBuilder::new(
727            ReportDetail::Summary,
728            InputDescriptor::Builtin {
729                name: "rosenbrock".into(),
730            },
731        );
732        let mut stats = SolveStatistics::default();
733        stats.iterations.push(IterRecord {
734            iter: 0,
735            objective: 1.0,
736            ..IterRecord::default()
737        });
738        b.ingest_stats(&stats);
739        let r = b.finish();
740        assert!(
741            r.iterations.is_empty(),
742            "Summary detail should drop iter history; got {} rows",
743            r.iterations.len()
744        );
745        // And the JSON should not include the key at all (skip-empty).
746        let json = serde_json::to_string(&r).unwrap();
747        assert!(!json.contains("\"iterations\":"), "json: {json}");
748    }
749
750    #[test]
751    fn full_detail_includes_iteration_rows() {
752        let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
753        let mut stats = SolveStatistics::default();
754        stats.iterations.push(IterRecord {
755            iter: 0,
756            objective: 1.0,
757            inf_pr: 0.5,
758            ..IterRecord::default()
759        });
760        stats.iterations.push(IterRecord {
761            iter: 1,
762            objective: 0.5,
763            inf_pr: 0.1,
764            ..IterRecord::default()
765        });
766        b.ingest_stats(&stats);
767        let r = b.finish();
768        assert_eq!(r.iterations.len(), 2);
769        assert_eq!(r.iterations[0].iter, 0);
770        assert_eq!(r.iterations[1].iter, 1);
771    }
772
773    #[test]
774    fn detail_parser_accepts_known_values() {
775        assert_eq!(
776            ReportDetail::parse("summary").unwrap(),
777            ReportDetail::Summary
778        );
779        assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
780        assert!(ReportDetail::parse("verbose").is_err());
781    }
782
783    #[test]
784    fn diverging_iterates_maps_to_unbounded_range() {
785        use ApplicationReturnStatus::*;
786        // M12 regression: DivergingIterates is Ipopt's unboundedness
787        // signal and must land in the AMPL 300 "unbounded" range, not
788        // the 400 "limit" range — matching upstream Ipopt's ASL driver
789        // and the CLI convex path (QpStatus::DualInfeasible → 300).
790        assert_eq!(status_to_solve_result_num(DivergingIterates), 300);
791
792        // Lock the surrounding range convention so the fix can't silently
793        // drift back: solved / infeasible / limit / failure buckets.
794        assert_eq!(status_to_solve_result_num(SolveSucceeded), 0);
795        assert_eq!(status_to_solve_result_num(InfeasibleProblemDetected), 200);
796        assert_eq!(
797            status_to_solve_result_num(MaximumIterationsExceeded),
798            400,
799            "iteration limit stays in the 400 range",
800        );
801        assert_eq!(
802            status_to_solve_result_num(SearchDirectionBecomesTooSmall),
803            400,
804        );
805        assert_eq!(status_to_solve_result_num(RestorationFailed), 500);
806    }
807
808    #[test]
809    fn result_id_is_unique_and_time_ordered() {
810        let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
811        std::thread::sleep(std::time::Duration::from_millis(2));
812        let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
813        assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
814        assert!(
815            b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
816            "second result_id should sort after first"
817        );
818    }
819}