Skip to main content

pounce_studio_core/
report.rs

1//! `pounce.solve-report/v1` JSON types.
2//!
3//! Schema-compatible with the writer in
4//! `crates/pounce-cli/src/solve_report.rs`. Re-defined here (rather than
5//! imported) so this crate does not pull in the algorithm/CLI stack —
6//! it should compile cleanly to `wasm32-unknown-unknown` for the
7//! VS Code webview shell.
8//!
9//! Drift handling:
10//!
11//! * **New writer fields**: silently ignored (serde's default
12//!   behaviour for unknown JSON keys is to drop them).
13//! * **Renamed or removed writer fields**: hard-fails with serde's
14//!   "missing field" error during deserialization, unless the field
15//!   here is marked `#[serde(default)]`. Additive fields like the
16//!   `restoration_*` counters carry the attribute so old reports
17//!   written before they existed still load.
18//! * **Schema version bump**: caught up front by the schema-tag check
19//!   in [`SolveReport::from_json_slice`] before any field-level
20//!   deserialization runs.
21//!
22//! When extending the writer with a non-additive change, bump the
23//! `schema` tag (`pounce.solve-report/v2`) and add a new branch here.
24
25use serde::{Deserialize, Serialize};
26
27/// JSON `schema` tag this crate understands. A report carrying any
28/// other value is rejected by [`SolveReport::from_json_slice`].
29pub const SOLVE_REPORT_SCHEMA: &str = "pounce.solve-report/v1";
30
31#[derive(Debug)]
32pub enum Error {
33    /// JSON did not parse.
34    Json(serde_json::Error),
35    /// The top-level `schema` tag was not [`SOLVE_REPORT_SCHEMA`].
36    SchemaMismatch { found: String },
37    /// A requested iteration index was out of range.
38    IterOutOfRange { k: usize, n: usize },
39    /// The report carried no per-iteration history (writer ran at
40    /// `--json-detail summary`).
41    NoIterations,
42    /// A binary iter-dump file was malformed (truncated, bad magic,
43    /// unsupported version).
44    IterDump(String),
45}
46
47impl std::fmt::Display for Error {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Json(e) => write!(f, "invalid JSON: {e}"),
51            Self::SchemaMismatch { found } => write!(
52                f,
53                "unexpected schema {found:?} (expected {SOLVE_REPORT_SCHEMA:?})",
54            ),
55            Self::IterOutOfRange { k, n } => {
56                write!(f, "iter {k} out of range; report has {n} iterations")
57            }
58            Self::NoIterations => write!(
59                f,
60                "report has no iteration history (rerun with --json-detail full)",
61            ),
62            Self::IterDump(msg) => write!(f, "iter-dump parse error: {msg}"),
63        }
64    }
65}
66
67impl std::error::Error for Error {
68    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
69        match self {
70            Self::Json(e) => Some(e),
71            _ => None,
72        }
73    }
74}
75
76impl From<serde_json::Error> for Error {
77    fn from(e: serde_json::Error) -> Self {
78        Self::Json(e)
79    }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SolveReport {
84    pub schema: String,
85    pub fair_metadata: FairMetadata,
86    pub problem: ProblemInfo,
87    pub solution: SolutionInfo,
88    pub statistics: StatisticsInfo,
89    #[serde(default)]
90    pub iterations: Vec<IterRecord>,
91    /// Aggregate linear-solver post-mortem (factor counts, fill ratio,
92    /// extremal pivots, final inertia). Added in pounce#?? — older
93    /// reports loaded with `#[serde(default)] = None`.
94    #[serde(default)]
95    pub linear_solver: Option<LinearSolverSummaryInfo>,
96}
97
98impl SolveReport {
99    /// Parse a JSON report from bytes. Validates the schema tag *first*
100    /// (before full struct deserialization) so a mismatched version
101    /// surfaces as [`Error::SchemaMismatch`] rather than a confusing
102    /// "missing field" JSON error.
103    pub fn from_json_slice(bytes: &[u8]) -> Result<Self, Error> {
104        #[derive(Deserialize)]
105        struct SchemaProbe {
106            schema: Option<String>,
107        }
108        let probe: SchemaProbe = serde_json::from_slice(bytes)?;
109        let found = probe.schema.unwrap_or_default();
110        if found != SOLVE_REPORT_SCHEMA {
111            return Err(Error::SchemaMismatch { found });
112        }
113        Ok(serde_json::from_slice(bytes)?)
114    }
115
116    /// Parse a JSON report from a `&str`. Convenience wrapper.
117    pub fn from_json_str(s: &str) -> Result<Self, Error> {
118        Self::from_json_slice(s.as_bytes())
119    }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct FairMetadata {
124    pub result_id: String,
125    pub created_at_iso: String,
126    pub created_at_unix_nanos: i128,
127    pub elapsed_seconds: f64,
128    pub solver: SolverIdentity,
129    pub license: String,
130    pub input: InputDescriptor,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct SolverIdentity {
135    pub name: String,
136    pub version: String,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub git_commit: Option<String>,
139    pub target_triple: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(tag = "kind", rename_all = "kebab-case")]
144pub enum InputDescriptor {
145    NlFile {
146        path: String,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        size_bytes: Option<u64>,
149    },
150    /// A Conic Benchmark Format (`.cbf`) instance — e.g. a CBLIB problem
151    /// solved through the convex conic driver. Mirrors the writer's
152    /// `pounce_solve_report::InputDescriptor::CbfFile` (`"kind": "cbf-file"`);
153    /// omitting it made serde reject the entire report.
154    CbfFile {
155        path: String,
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        size_bytes: Option<u64>,
158    },
159    Builtin {
160        name: String,
161    },
162    TnlpDirect,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ProblemInfo {
167    pub n_variables: i32,
168    pub n_constraints: i32,
169    pub n_objectives: i32,
170    pub minimize: bool,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub nnz_jac_g: Option<i32>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub nnz_h_lag: Option<i32>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SolutionInfo {
179    /// Status string verbatim from
180    /// `pounce_nlp::return_codes::ApplicationReturnStatus` — we keep it
181    /// untyped here to avoid pulling in the nlp crate; consumers compare
182    /// against known tags (`"SolveSucceeded"`, `"MaximumIterationsExceeded"`,
183    /// etc.).
184    pub status: String,
185    pub solve_result_num: i32,
186    pub objective: f64,
187    #[serde(default)]
188    pub x: Vec<f64>,
189    #[serde(default)]
190    pub lambda: Vec<f64>,
191    #[serde(default)]
192    pub suffixes: Vec<SolutionSuffix>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SolutionSuffix {
197    pub name: String,
198    pub target: String,
199    pub kind: String,
200    #[serde(default)]
201    pub values: Vec<f64>,
202    #[serde(default)]
203    pub int_values: Vec<i32>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct StatisticsInfo {
208    pub iteration_count: i32,
209    pub final_objective: f64,
210    pub final_scaled_objective: f64,
211    pub final_dual_inf: f64,
212    pub final_constr_viol: f64,
213    pub final_compl: f64,
214    pub final_kkt_error: f64,
215    pub num_obj_evals: i32,
216    pub num_constr_evals: i32,
217    pub num_obj_grad_evals: i32,
218    pub num_constr_jac_evals: i32,
219    pub num_hess_evals: i32,
220    pub total_wallclock_time_secs: f64,
221    // Restoration counters were added to the writer in pounce#12 — let
222    // older reports written before that load with zeros rather than
223    // hard-failing with "missing field".
224    #[serde(default)]
225    pub restoration_calls: i32,
226    #[serde(default)]
227    pub restoration_inner_iters: i32,
228    #[serde(default)]
229    pub restoration_outer_iters: i32,
230    #[serde(default)]
231    pub restoration_wall_secs: f64,
232}
233
234/// Aggregate linear-solver post-mortem mirror — schema-compatible
235/// with `pounce_cli::solve_report::LinearSolverSummaryInfo` and
236/// ultimately `pounce_linsol::summary::LinearSolverSummary`. Loaded
237/// from the report's optional `linear_solver` object. All numeric
238/// extremals are `Option` because the backend declines to populate
239/// them when no factor has run yet.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct LinearSolverSummaryInfo {
242    pub solver_name: String,
243    pub n_factors: u64,
244    pub n_pattern_reuse: u64,
245    pub n_pattern_changes: u64,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub max_fill_ratio: Option<f64>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub min_abs_pivot: Option<f64>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub max_abs_pivot: Option<f64>,
252    /// `(positive, negative, zero)` inertia of the final factorisation.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub last_inertia: Option<(usize, usize, usize)>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub last_nnz_a: Option<usize>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub last_nnz_l: Option<usize>,
259}
260
261/// One row of per-iteration trajectory; mirrors
262/// `pounce_nlp::solve_statistics::IterRecord` field-by-field.
263#[derive(Debug, Default, Clone, Serialize, Deserialize)]
264pub struct IterRecord {
265    pub iter: i32,
266    pub objective: f64,
267    pub inf_pr: f64,
268    pub inf_du: f64,
269    pub mu: f64,
270    pub d_norm: f64,
271    pub regularization: f64,
272    pub alpha_dual: f64,
273    pub alpha_primal: f64,
274    /// Single-character tag (`f`, `h`, `r`, ...) describing the
275    /// alpha-primal column. `'r'` indicates a restoration iteration.
276    pub alpha_primal_char: char,
277    pub ls_trials: i32,
278}