use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use pounce_common::types::{Index, Number};
use pounce_linsol::summary::LinearSolverSummary;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::solve_statistics::{IterRecord, SolveStatistics};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReportDetail {
Summary,
Full,
}
impl ReportDetail {
pub fn parse(s: &str) -> Result<Self, String> {
match s.to_ascii_lowercase().as_str() {
"summary" => Ok(ReportDetail::Summary),
"full" => Ok(ReportDetail::Full),
other => Err(format!(
"unknown --json-detail '{other}' (expected: summary | full)"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolveReport {
pub schema: String,
pub fair_metadata: FairMetadata,
pub problem: ProblemInfo,
pub solution: SolutionInfo,
pub statistics: StatisticsInfo,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub iterations: Vec<IterRecord>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub linear_solver: Option<LinearSolverSummaryInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinearSolverSummaryInfo {
pub solver_name: String,
pub n_factors: u64,
pub n_pattern_reuse: u64,
pub n_pattern_changes: u64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub max_fill_ratio: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub min_abs_pivot: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub max_abs_pivot: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_inertia: Option<(usize, usize, usize)>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_nnz_a: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_nnz_l: Option<usize>,
}
impl From<LinearSolverSummary> for LinearSolverSummaryInfo {
fn from(s: LinearSolverSummary) -> Self {
Self {
solver_name: s.solver_name,
n_factors: s.n_factors,
n_pattern_reuse: s.n_pattern_reuse,
n_pattern_changes: s.n_pattern_changes,
max_fill_ratio: s.max_fill_ratio,
min_abs_pivot: s.min_abs_pivot,
max_abs_pivot: s.max_abs_pivot,
last_inertia: s.last_inertia,
last_nnz_a: s.last_nnz_a,
last_nnz_l: s.last_nnz_l,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FairMetadata {
pub result_id: String,
pub created_at_iso: String,
pub created_at_unix_nanos: i128,
pub elapsed_seconds: Number,
pub solver: SolverIdentity,
pub license: String,
pub input: InputDescriptor,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverIdentity {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_commit: Option<String>,
pub target_triple: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum InputDescriptor {
NlFile {
path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
size_bytes: Option<u64>,
},
Builtin {
name: String,
},
TnlpDirect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemInfo {
pub n_variables: Index,
pub n_constraints: Index,
pub n_objectives: Index,
pub minimize: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub nnz_jac_g: Option<Index>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nnz_h_lag: Option<Index>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolutionInfo {
pub status: ApplicationReturnStatus,
pub solve_result_num: i32,
pub objective: Number,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub x: Vec<Number>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub lambda: Vec<Number>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub suffixes: Vec<SolutionSuffix>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolutionSuffix {
pub name: String,
pub target: String,
pub kind: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub values: Vec<Number>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub int_values: Vec<Index>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatisticsInfo {
pub iteration_count: Index,
pub final_objective: Number,
pub final_scaled_objective: Number,
pub final_dual_inf: Number,
pub final_constr_viol: Number,
pub final_compl: Number,
pub final_kkt_error: Number,
pub num_obj_evals: Index,
pub num_constr_evals: Index,
pub num_obj_grad_evals: Index,
pub num_constr_jac_evals: Index,
pub num_hess_evals: Index,
pub total_wallclock_time_secs: Number,
pub restoration_calls: Index,
pub restoration_inner_iters: Index,
pub restoration_outer_iters: Index,
pub restoration_wall_secs: Number,
}
pub struct ReportBuilder {
detail: ReportDetail,
started_at: SystemTime,
started_unix_nanos: i128,
pub input: InputDescriptor,
pub problem: ProblemInfo,
pub solution: SolutionInfo,
pub stats: StatisticsInfo,
pub iterations: Vec<IterRecord>,
pub linear_solver: Option<LinearSolverSummaryInfo>,
}
impl ReportBuilder {
pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
let now = SystemTime::now();
let nanos = now
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0);
Self {
detail,
started_at: now,
started_unix_nanos: nanos,
input,
problem: ProblemInfo {
n_variables: 0,
n_constraints: 0,
n_objectives: 0,
minimize: true,
nnz_jac_g: None,
nnz_h_lag: None,
},
solution: SolutionInfo {
status: ApplicationReturnStatus::InternalError,
solve_result_num: 500,
objective: 0.0,
x: Vec::new(),
lambda: Vec::new(),
suffixes: Vec::new(),
},
stats: empty_stats(),
iterations: Vec::new(),
linear_solver: None,
}
}
pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
self.linear_solver = Some(summary.into());
}
pub fn ingest_stats(&mut self, src: &SolveStatistics) {
self.stats = StatisticsInfo {
iteration_count: src.iteration_count,
final_objective: src.final_objective,
final_scaled_objective: src.final_scaled_objective,
final_dual_inf: src.final_dual_inf,
final_constr_viol: src.final_constr_viol,
final_compl: src.final_compl,
final_kkt_error: src.final_kkt_error,
num_obj_evals: src.num_obj_evals,
num_constr_evals: src.num_constr_evals,
num_obj_grad_evals: src.num_obj_grad_evals,
num_constr_jac_evals: src.num_constr_jac_evals,
num_hess_evals: src.num_hess_evals,
total_wallclock_time_secs: src.total_wallclock_time_secs,
restoration_calls: src.restoration_calls,
restoration_inner_iters: src.restoration_inner_iters,
restoration_outer_iters: src.restoration_outer_iters,
restoration_wall_secs: src.restoration_wall_secs,
};
if matches!(self.detail, ReportDetail::Full) {
self.iterations = src.iterations.clone();
}
}
pub fn finish(self) -> SolveReport {
let elapsed = self
.started_at
.elapsed()
.map(|d| d.as_secs_f64())
.unwrap_or(0.0);
let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
SolveReport {
schema: "pounce.solve-report/v1".to_string(),
fair_metadata: FairMetadata {
result_id,
created_at_iso,
created_at_unix_nanos: self.started_unix_nanos,
elapsed_seconds: elapsed,
solver: SolverIdentity {
name: "pounce".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
target_triple: TARGET_TRIPLE.to_string(),
},
license: "EPL-2.0".to_string(),
input: self.input,
},
problem: self.problem,
solution: self.solution,
statistics: self.stats,
iterations: self.iterations,
linear_solver: self.linear_solver,
}
}
}
const TARGET_TRIPLE: &str = match option_env!("TARGET") {
Some(t) => t,
None => "unknown",
};
fn empty_stats() -> StatisticsInfo {
StatisticsInfo {
iteration_count: 0,
final_objective: 0.0,
final_scaled_objective: 0.0,
final_dual_inf: 0.0,
final_constr_viol: 0.0,
final_compl: 0.0,
final_kkt_error: 0.0,
num_obj_evals: 0,
num_constr_evals: 0,
num_obj_grad_evals: 0,
num_constr_jac_evals: 0,
num_hess_evals: 0,
total_wallclock_time_secs: 0.0,
restoration_calls: 0,
restoration_inner_iters: 0,
restoration_outer_iters: 0,
restoration_wall_secs: 0.0,
}
}
pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
use ApplicationReturnStatus::*;
match status {
SolveSucceeded => 0,
SolvedToAcceptableLevel => 100,
FeasiblePointFound => 100,
InfeasibleProblemDetected => 200,
SearchDirectionBecomesTooSmall => 400,
DivergingIterates => 401,
MaximumIterationsExceeded => 400,
MaximumCpuTimeExceeded => 400,
MaximumWallTimeExceeded => 400,
UserRequestedStop => 502,
RestorationFailed => 500,
ErrorInStepComputation => 500,
InvalidNumberDetected => 500,
InternalError => 500,
UnrecoverableException => 500,
NonIpoptExceptionThrown => 500,
InsufficientMemory => 503,
InvalidProblemDefinition => 504,
InvalidOption => 504,
NotEnoughDegreesOfFreedom => 504,
}
}
pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
let s = serde_json::to_string_pretty(report)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(path, &s)?;
Ok(s.len())
}
fn unix_nanos_to_iso(nanos: i128) -> String {
let total_secs = nanos.div_euclid(1_000_000_000) as i64;
let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
let millis = frac_nanos / 1_000_000;
let days = total_secs.div_euclid(86_400);
let secs_of_day = total_secs.rem_euclid(86_400);
let hh = (secs_of_day / 3600) as i32;
let mm = ((secs_of_day % 3600) / 60) as i32;
let ss = (secs_of_day % 60) as i32;
let z: i64 = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as i64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let mut y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as i32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
if m <= 2 {
y += 1;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
y, m, d, hh, mm, ss, millis
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso_formatter_matches_known_epochs() {
assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
assert_eq!(
unix_nanos_to_iso(946_684_800_000_000_000),
"2000-01-01T00:00:00.000Z",
);
let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
}
#[test]
fn report_serializes_round_trip() {
let mut b = ReportBuilder::new(
ReportDetail::Summary,
InputDescriptor::NlFile {
path: PathBuf::from("/tmp/foo.nl"),
size_bytes: Some(123),
},
);
b.problem.n_variables = 5;
b.problem.n_constraints = 4;
b.solution.status = ApplicationReturnStatus::SolveSucceeded;
b.solution.solve_result_num = 0;
b.solution.objective = 0.55;
b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
b.stats.iteration_count = 9;
let report = b.finish();
let json = serde_json::to_string_pretty(&report).expect("serialize");
let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.schema, "pounce.solve-report/v1");
assert_eq!(back.problem.n_variables, 5);
assert_eq!(back.solution.x.len(), 5);
assert!(matches!(
back.solution.status,
ApplicationReturnStatus::SolveSucceeded,
));
}
#[test]
fn summary_detail_omits_iterations_block() {
let mut b = ReportBuilder::new(
ReportDetail::Summary,
InputDescriptor::Builtin {
name: "rosenbrock".into(),
},
);
let mut stats = SolveStatistics::default();
stats.iterations.push(IterRecord {
iter: 0,
objective: 1.0,
..IterRecord::default()
});
b.ingest_stats(&stats);
let r = b.finish();
assert!(
r.iterations.is_empty(),
"Summary detail should drop iter history; got {} rows",
r.iterations.len()
);
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("\"iterations\":"), "json: {json}");
}
#[test]
fn full_detail_includes_iteration_rows() {
let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
let mut stats = SolveStatistics::default();
stats.iterations.push(IterRecord {
iter: 0,
objective: 1.0,
inf_pr: 0.5,
..IterRecord::default()
});
stats.iterations.push(IterRecord {
iter: 1,
objective: 0.5,
inf_pr: 0.1,
..IterRecord::default()
});
b.ingest_stats(&stats);
let r = b.finish();
assert_eq!(r.iterations.len(), 2);
assert_eq!(r.iterations[0].iter, 0);
assert_eq!(r.iterations[1].iter, 1);
}
#[test]
fn detail_parser_accepts_known_values() {
assert_eq!(
ReportDetail::parse("summary").unwrap(),
ReportDetail::Summary
);
assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
assert!(ReportDetail::parse("verbose").is_err());
}
#[test]
fn result_id_is_unique_and_time_ordered() {
let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
assert!(
b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
"second result_id should sort after first"
);
}
}