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    Builtin {
151        name: String,
152    },
153    TnlpDirect,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct ProblemInfo {
158    pub n_variables: i32,
159    pub n_constraints: i32,
160    pub n_objectives: i32,
161    pub minimize: bool,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub nnz_jac_g: Option<i32>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub nnz_h_lag: Option<i32>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct SolutionInfo {
170    /// Status string verbatim from
171    /// `pounce_nlp::return_codes::ApplicationReturnStatus` — we keep it
172    /// untyped here to avoid pulling in the nlp crate; consumers compare
173    /// against known tags (`"SolveSucceeded"`, `"MaximumIterationsExceeded"`,
174    /// etc.).
175    pub status: String,
176    pub solve_result_num: i32,
177    pub objective: f64,
178    #[serde(default)]
179    pub x: Vec<f64>,
180    #[serde(default)]
181    pub lambda: Vec<f64>,
182    #[serde(default)]
183    pub suffixes: Vec<SolutionSuffix>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SolutionSuffix {
188    pub name: String,
189    pub target: String,
190    pub kind: String,
191    #[serde(default)]
192    pub values: Vec<f64>,
193    #[serde(default)]
194    pub int_values: Vec<i32>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct StatisticsInfo {
199    pub iteration_count: i32,
200    pub final_objective: f64,
201    pub final_scaled_objective: f64,
202    pub final_dual_inf: f64,
203    pub final_constr_viol: f64,
204    pub final_compl: f64,
205    pub final_kkt_error: f64,
206    pub num_obj_evals: i32,
207    pub num_constr_evals: i32,
208    pub num_obj_grad_evals: i32,
209    pub num_constr_jac_evals: i32,
210    pub num_hess_evals: i32,
211    pub total_wallclock_time_secs: f64,
212    // Restoration counters were added to the writer in pounce#12 — let
213    // older reports written before that load with zeros rather than
214    // hard-failing with "missing field".
215    #[serde(default)]
216    pub restoration_calls: i32,
217    #[serde(default)]
218    pub restoration_inner_iters: i32,
219    #[serde(default)]
220    pub restoration_outer_iters: i32,
221    #[serde(default)]
222    pub restoration_wall_secs: f64,
223}
224
225/// Aggregate linear-solver post-mortem mirror — schema-compatible
226/// with `pounce_cli::solve_report::LinearSolverSummaryInfo` and
227/// ultimately `pounce_linsol::summary::LinearSolverSummary`. Loaded
228/// from the report's optional `linear_solver` object. All numeric
229/// extremals are `Option` because the backend declines to populate
230/// them when no factor has run yet.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct LinearSolverSummaryInfo {
233    pub solver_name: String,
234    pub n_factors: u64,
235    pub n_pattern_reuse: u64,
236    pub n_pattern_changes: u64,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub max_fill_ratio: Option<f64>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub min_abs_pivot: Option<f64>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub max_abs_pivot: Option<f64>,
243    /// `(positive, negative, zero)` inertia of the final factorisation.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub last_inertia: Option<(usize, usize, usize)>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub last_nnz_a: Option<usize>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub last_nnz_l: Option<usize>,
250}
251
252/// One row of per-iteration trajectory; mirrors
253/// `pounce_nlp::solve_statistics::IterRecord` field-by-field.
254#[derive(Debug, Default, Clone, Serialize, Deserialize)]
255pub struct IterRecord {
256    pub iter: i32,
257    pub objective: f64,
258    pub inf_pr: f64,
259    pub inf_du: f64,
260    pub mu: f64,
261    pub d_norm: f64,
262    pub regularization: f64,
263    pub alpha_dual: f64,
264    pub alpha_primal: f64,
265    /// Single-character tag (`f`, `h`, `r`, ...) describing the
266    /// alpha-primal column. `'r'` indicates a restoration iteration.
267    pub alpha_primal_char: char,
268    pub ls_trials: i32,
269}