1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ReportDetail {
45 Summary,
48 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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SolveReport {
69 pub schema: String,
72 pub fair_metadata: FairMetadata,
74 pub problem: ProblemInfo,
76 pub solution: SolutionInfo,
78 pub statistics: StatisticsInfo,
80 #[serde(skip_serializing_if = "Vec::is_empty", default)]
83 pub iterations: Vec<IterRecord>,
84 #[serde(skip_serializing_if = "Option::is_none", default)]
92 pub linear_solver: Option<LinearSolverSummaryInfo>,
93}
94
95#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct FairMetadata {
148 pub result_id: String,
152 pub created_at_iso: String,
154 pub created_at_unix_nanos: i128,
158 pub elapsed_seconds: Number,
161 pub solver: SolverIdentity,
163 pub license: String,
165 pub input: InputDescriptor,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SolverIdentity {
172 pub name: String,
173 pub version: String,
174 #[serde(skip_serializing_if = "Option::is_none")]
179 pub git_commit: Option<String>,
180 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 #[serde(skip_serializing_if = "Option::is_none")]
208 pub nnz_jac_g: Option<Index>,
209 #[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 pub status: ApplicationReturnStatus,
219 pub solve_result_num: i32,
221 pub objective: Number,
224 #[serde(skip_serializing_if = "Vec::is_empty", default)]
227 pub x: Vec<Number>,
228 #[serde(skip_serializing_if = "Vec::is_empty", default)]
231 pub lambda: Vec<Number>,
232 #[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 pub target: String,
245 pub kind: String,
247 #[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#[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
279pub 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 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 pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
334 self.linear_solver = Some(summary.into());
335 }
336
337 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
398const TARGET_TRIPLE: &str = match option_env!("TARGET") {
402 Some(t) => t,
403 None => "unknown",
404};
405
406fn empty_stats() -> StatisticsInfo {
407 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
433pub 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
442fn 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 let z: i64 = days + 719468;
472 let era = if z >= 0 { z } else { z - 146096 } / 146097;
473 let doe = (z - era * 146097) as i64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let mut y = yoe + era * 400;
476 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;
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 assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
498 assert_eq!(
500 unix_nanos_to_iso(946_684_800_000_000_000),
501 "2000-01-01T00:00:00.000Z",
502 );
503 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 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}