Skip to main content

pounce_cli/
solve_report.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    Builtin {
194        name: String,
195    },
196    TnlpDirect,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ProblemInfo {
201    pub n_variables: Index,
202    pub n_constraints: Index,
203    pub n_objectives: Index,
204    pub minimize: bool,
205    /// Number of non-zeros declared by the TNLP for the constraint
206    /// Jacobian. `None` if not exposed by the input path.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub nnz_jac_g: Option<Index>,
209    /// Number of non-zeros declared for the Lagrangian Hessian.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub nnz_h_lag: Option<Index>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SolutionInfo {
216    /// `SolveSucceeded`, `MaximumIterationsExceeded`, etc. The string
217    /// form is the Rust enum variant name verbatim.
218    pub status: ApplicationReturnStatus,
219    /// AMPL-style solve-result code (Gay 2005, §5 p. 23 table).
220    pub solve_result_num: i32,
221    /// Final unscaled objective value (mirrors
222    /// `SolveStatistics::final_objective`). `NaN` if unknown.
223    pub objective: Number,
224    /// Final primal vector, length `problem.n_variables`. Empty if
225    /// not captured.
226    #[serde(skip_serializing_if = "Vec::is_empty", default)]
227    pub x: Vec<Number>,
228    /// Final dual (constraint multiplier) vector, length
229    /// `problem.n_constraints`.
230    #[serde(skip_serializing_if = "Vec::is_empty", default)]
231    pub lambda: Vec<Number>,
232    /// Optional sIPOPT-style suffix blocks (`sens_sol_state_1` etc.).
233    /// Stored as a flat map keyed by suffix name → list of
234    /// `(index, value)` pairs, matching the AMPL `.sol` shape.
235    /// Empty when no sensitivity / reduced-Hessian step ran.
236    #[serde(skip_serializing_if = "Vec::is_empty", default)]
237    pub suffixes: Vec<SolutionSuffix>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct SolutionSuffix {
242    pub name: String,
243    /// `"var" | "con" | "obj" | "problem"` per AMPL convention.
244    pub target: String,
245    /// `"int"` or `"real"`.
246    pub kind: String,
247    /// Dense values (length = target dimension); zero-filled for
248    /// slots the writer didn't populate. Real-typed values are stored
249    /// here; int-typed in `int_values`.
250    #[serde(skip_serializing_if = "Vec::is_empty", default)]
251    pub values: Vec<Number>,
252    #[serde(skip_serializing_if = "Vec::is_empty", default)]
253    pub int_values: Vec<Index>,
254}
255
256/// Subset of `SolveStatistics` projected for the report. Mirrors the
257/// fields the existing console summary prints.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct StatisticsInfo {
260    pub iteration_count: Index,
261    pub final_objective: Number,
262    pub final_scaled_objective: Number,
263    pub final_dual_inf: Number,
264    pub final_constr_viol: Number,
265    pub final_compl: Number,
266    pub final_kkt_error: Number,
267    pub num_obj_evals: Index,
268    pub num_constr_evals: Index,
269    pub num_obj_grad_evals: Index,
270    pub num_constr_jac_evals: Index,
271    pub num_hess_evals: Index,
272    pub total_wallclock_time_secs: Number,
273    pub restoration_calls: Index,
274    pub restoration_inner_iters: Index,
275    pub restoration_outer_iters: Index,
276    pub restoration_wall_secs: Number,
277}
278
279/// Builder collecting the inputs for a [`SolveReport`]. The CLI
280/// drivers populate one of these as they walk through the solve and
281/// `finish()` it at the end.
282pub struct ReportBuilder {
283    detail: ReportDetail,
284    started_at: SystemTime,
285    started_unix_nanos: i128,
286    pub input: InputDescriptor,
287    pub problem: ProblemInfo,
288    pub solution: SolutionInfo,
289    pub stats: StatisticsInfo,
290    pub iterations: Vec<IterRecord>,
291    pub linear_solver: Option<LinearSolverSummaryInfo>,
292}
293
294impl ReportBuilder {
295    pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
296        let now = SystemTime::now();
297        let nanos = now
298            .duration_since(UNIX_EPOCH)
299            .map(|d| d.as_nanos() as i128)
300            .unwrap_or(0);
301        Self {
302            detail,
303            started_at: now,
304            started_unix_nanos: nanos,
305            input,
306            problem: ProblemInfo {
307                n_variables: 0,
308                n_constraints: 0,
309                n_objectives: 0,
310                minimize: true,
311                nnz_jac_g: None,
312                nnz_h_lag: None,
313            },
314            solution: SolutionInfo {
315                status: ApplicationReturnStatus::InternalError,
316                solve_result_num: 500,
317                // 0.0 (not NaN) so JSON round-trips. Callers that
318                // need "unknown objective" semantics check
319                // `statistics.iteration_count > 0` first.
320                objective: 0.0,
321                x: Vec::new(),
322                lambda: Vec::new(),
323                suffixes: Vec::new(),
324            },
325            stats: empty_stats(),
326            iterations: Vec::new(),
327            linear_solver: None,
328        }
329    }
330
331    /// Attach a linear-solver post-mortem. Called once per solve after
332    /// `optimize_tnlp` returns and before [`Self::finish`].
333    pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
334        self.linear_solver = Some(summary.into());
335    }
336
337    /// Pull `iteration_count`, `final_*`, and counters into the
338    /// `stats` slot; copy `iterations` only if detail = Full.
339    pub fn ingest_stats(&mut self, src: &SolveStatistics) {
340        self.stats = StatisticsInfo {
341            iteration_count: src.iteration_count,
342            final_objective: src.final_objective,
343            final_scaled_objective: src.final_scaled_objective,
344            final_dual_inf: src.final_dual_inf,
345            final_constr_viol: src.final_constr_viol,
346            final_compl: src.final_compl,
347            final_kkt_error: src.final_kkt_error,
348            num_obj_evals: src.num_obj_evals,
349            num_constr_evals: src.num_constr_evals,
350            num_obj_grad_evals: src.num_obj_grad_evals,
351            num_constr_jac_evals: src.num_constr_jac_evals,
352            num_hess_evals: src.num_hess_evals,
353            total_wallclock_time_secs: src.total_wallclock_time_secs,
354            restoration_calls: src.restoration_calls,
355            restoration_inner_iters: src.restoration_inner_iters,
356            restoration_outer_iters: src.restoration_outer_iters,
357            restoration_wall_secs: src.restoration_wall_secs,
358        };
359        if matches!(self.detail, ReportDetail::Full) {
360            self.iterations = src.iterations.clone();
361        }
362    }
363
364    pub fn finish(self) -> SolveReport {
365        let elapsed = self
366            .started_at
367            .elapsed()
368            .map(|d| d.as_secs_f64())
369            .unwrap_or(0.0);
370        let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
371        let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
372
373        SolveReport {
374            schema: "pounce.solve-report/v1".to_string(),
375            fair_metadata: FairMetadata {
376                result_id,
377                created_at_iso,
378                created_at_unix_nanos: self.started_unix_nanos,
379                elapsed_seconds: elapsed,
380                solver: SolverIdentity {
381                    name: "pounce".to_string(),
382                    version: env!("CARGO_PKG_VERSION").to_string(),
383                    git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
384                    target_triple: TARGET_TRIPLE.to_string(),
385                },
386                license: "EPL-2.0".to_string(),
387                input: self.input,
388            },
389            problem: self.problem,
390            solution: self.solution,
391            statistics: self.stats,
392            iterations: self.iterations,
393            linear_solver: self.linear_solver,
394        }
395    }
396}
397
398/// `TARGET` is set by Cargo when building this crate. Doesn't fail to
399/// compile if absent (older Cargo or some tooling); falls back to
400/// "unknown".
401const TARGET_TRIPLE: &str = match option_env!("TARGET") {
402    Some(t) => t,
403    None => "unknown",
404};
405
406fn empty_stats() -> StatisticsInfo {
407    // All scalar fields start at 0.0 (not NaN) so the report
408    // round-trips through `serde_json` — JSON has no NaN literal, and
409    // serde_json's default is to write `null` for NaN, which then
410    // fails to deserialize back into `Number`. Callers reading these
411    // pre-solve treat `iteration_count == 0` as "no solve yet".
412    StatisticsInfo {
413        iteration_count: 0,
414        final_objective: 0.0,
415        final_scaled_objective: 0.0,
416        final_dual_inf: 0.0,
417        final_constr_viol: 0.0,
418        final_compl: 0.0,
419        final_kkt_error: 0.0,
420        num_obj_evals: 0,
421        num_constr_evals: 0,
422        num_obj_grad_evals: 0,
423        num_constr_jac_evals: 0,
424        num_hess_evals: 0,
425        total_wallclock_time_secs: 0.0,
426        restoration_calls: 0,
427        restoration_inner_iters: 0,
428        restoration_outer_iters: 0,
429        restoration_wall_secs: 0.0,
430    }
431}
432
433/// Write a [`SolveReport`] to `path` as pretty-printed JSON. Returns
434/// bytes written on success.
435pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
436    let s = serde_json::to_string_pretty(report)
437        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
438    std::fs::write(path, &s)?;
439    Ok(s.len())
440}
441
442/// Convert Unix nanoseconds since the epoch to an ISO-8601 UTC
443/// timestamp `YYYY-MM-DDTHH:MM:SS.sssZ`. Pure stdlib; no chrono /
444/// time dependency. The conversion is based on the proleptic
445/// Gregorian calendar formula from Howard Hinnant's "date" reference
446/// (https://howardhinnant.github.io/date_algorithms.html), `days_from_civil`
447/// in reverse — verified against `date -u -r <secs>` for several
448/// epochs on 2026-05-14.
449fn unix_nanos_to_iso(nanos: i128) -> String {
450    let total_secs = nanos.div_euclid(1_000_000_000) as i64;
451    let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
452    let millis = frac_nanos / 1_000_000;
453
454    let days = total_secs.div_euclid(86_400);
455    let secs_of_day = total_secs.rem_euclid(86_400);
456    let hh = (secs_of_day / 3600) as i32;
457    let mm = ((secs_of_day % 3600) / 60) as i32;
458    let ss = (secs_of_day % 60) as i32;
459
460    // Howard Hinnant's `civil_from_days` algorithm:
461    //   z = days + 719468
462    //   era = (z >= 0 ? z : z - 146096) / 146097
463    //   doe = z - era*146097
464    //   yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365
465    //   y = yoe + era*400
466    //   doy = doe - (365*yoe + yoe/4 - yoe/100)
467    //   mp = (5*doy + 2) / 153
468    //   d = doy - (153*mp + 2)/5 + 1
469    //   m = mp < 10 ? mp + 3 : mp - 9
470    //   y += (m <= 2)
471    let z: i64 = days + 719468;
472    let era = if z >= 0 { z } else { z - 146096 } / 146097;
473    let doe = (z - era * 146097) as i64; // [0, 146096]
474    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
475    let mut y = yoe + era * 400;
476    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
477    let mp = (5 * doy + 2) / 153; // [0, 11]
478    let d = (doy - (153 * mp + 2) / 5 + 1) as i32;
479    let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
480    if m <= 2 {
481        y += 1;
482    }
483
484    format!(
485        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
486        y, m, d, hh, mm, ss, millis
487    )
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn iso_formatter_matches_known_epochs() {
496        // Epoch.
497        assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
498        // 2000-01-01T00:00:00Z = 946684800 seconds.
499        assert_eq!(
500            unix_nanos_to_iso(946_684_800_000_000_000),
501            "2000-01-01T00:00:00.000Z",
502        );
503        // 2024-02-29T12:34:56.789Z (leap-year sanity check).
504        // Seconds: (2024 - 1970) * 365.25 days * 86400 ≈ 1709209296 — let's compute exactly.
505        // Days from 1970-01-01 to 2024-02-29: 19782.
506        // 19782 * 86400 = 1709164800. Plus 12*3600 + 34*60 + 56 = 45296.
507        // Total = 1709210096.
508        let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
509        assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
510    }
511
512    #[test]
513    fn report_serializes_round_trip() {
514        let mut b = ReportBuilder::new(
515            ReportDetail::Summary,
516            InputDescriptor::NlFile {
517                path: PathBuf::from("/tmp/foo.nl"),
518                size_bytes: Some(123),
519            },
520        );
521        b.problem.n_variables = 5;
522        b.problem.n_constraints = 4;
523        b.solution.status = ApplicationReturnStatus::SolveSucceeded;
524        b.solution.solve_result_num = 0;
525        b.solution.objective = 0.55;
526        b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
527        b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
528        b.stats.iteration_count = 9;
529
530        let report = b.finish();
531        let json = serde_json::to_string_pretty(&report).expect("serialize");
532        let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
533        assert_eq!(back.schema, "pounce.solve-report/v1");
534        assert_eq!(back.problem.n_variables, 5);
535        assert_eq!(back.solution.x.len(), 5);
536        assert!(matches!(
537            back.solution.status,
538            ApplicationReturnStatus::SolveSucceeded,
539        ));
540    }
541
542    #[test]
543    fn summary_detail_omits_iterations_block() {
544        let mut b = ReportBuilder::new(
545            ReportDetail::Summary,
546            InputDescriptor::Builtin {
547                name: "rosenbrock".into(),
548            },
549        );
550        let mut stats = SolveStatistics::default();
551        stats.iterations.push(IterRecord {
552            iter: 0,
553            objective: 1.0,
554            ..IterRecord::default()
555        });
556        b.ingest_stats(&stats);
557        let r = b.finish();
558        assert!(
559            r.iterations.is_empty(),
560            "Summary detail should drop iter history; got {} rows",
561            r.iterations.len()
562        );
563        // And the JSON should not include the key at all (skip-empty).
564        let json = serde_json::to_string(&r).unwrap();
565        assert!(!json.contains("\"iterations\":"), "json: {json}");
566    }
567
568    #[test]
569    fn full_detail_includes_iteration_rows() {
570        let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
571        let mut stats = SolveStatistics::default();
572        stats.iterations.push(IterRecord {
573            iter: 0,
574            objective: 1.0,
575            inf_pr: 0.5,
576            ..IterRecord::default()
577        });
578        stats.iterations.push(IterRecord {
579            iter: 1,
580            objective: 0.5,
581            inf_pr: 0.1,
582            ..IterRecord::default()
583        });
584        b.ingest_stats(&stats);
585        let r = b.finish();
586        assert_eq!(r.iterations.len(), 2);
587        assert_eq!(r.iterations[0].iter, 0);
588        assert_eq!(r.iterations[1].iter, 1);
589    }
590
591    #[test]
592    fn detail_parser_accepts_known_values() {
593        assert_eq!(
594            ReportDetail::parse("summary").unwrap(),
595            ReportDetail::Summary
596        );
597        assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
598        assert!(ReportDetail::parse("verbose").is_err());
599    }
600
601    #[test]
602    fn result_id_is_unique_and_time_ordered() {
603        let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
604        std::thread::sleep(std::time::Duration::from_millis(2));
605        let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
606        assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
607        assert!(
608            b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
609            "second result_id should sort after first"
610        );
611    }
612}