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