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 CbfFile {
196 path: PathBuf,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 size_bytes: Option<u64>,
199 },
200 Builtin {
201 name: String,
202 },
203 TnlpDirect,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ProblemInfo {
208 pub n_variables: Index,
209 pub n_constraints: Index,
210 pub n_objectives: Index,
211 pub minimize: bool,
212 #[serde(skip_serializing_if = "Option::is_none")]
215 pub nnz_jac_g: Option<Index>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub nnz_h_lag: Option<Index>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct SolutionInfo {
223 pub status: ApplicationReturnStatus,
226 pub solve_result_num: i32,
228 pub objective: Number,
231 #[serde(skip_serializing_if = "Vec::is_empty", default)]
234 pub x: Vec<Number>,
235 #[serde(skip_serializing_if = "Vec::is_empty", default)]
238 pub lambda: Vec<Number>,
239 #[serde(skip_serializing_if = "Vec::is_empty", default)]
244 pub suffixes: Vec<SolutionSuffix>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct SolutionSuffix {
249 pub name: String,
250 pub target: String,
252 pub kind: String,
254 #[serde(skip_serializing_if = "Vec::is_empty", default)]
258 pub values: Vec<Number>,
259 #[serde(skip_serializing_if = "Vec::is_empty", default)]
260 pub int_values: Vec<Index>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct StatisticsInfo {
267 pub iteration_count: Index,
268 pub final_objective: Number,
269 pub final_scaled_objective: Number,
270 pub final_dual_inf: Number,
271 pub final_constr_viol: Number,
272 pub final_compl: Number,
273 pub final_kkt_error: Number,
274 pub num_obj_evals: Index,
275 pub num_constr_evals: Index,
276 pub num_obj_grad_evals: Index,
277 pub num_constr_jac_evals: Index,
278 pub num_hess_evals: Index,
279 pub total_wallclock_time_secs: Number,
280 pub restoration_calls: Index,
281 pub restoration_inner_iters: Index,
282 pub restoration_outer_iters: Index,
283 pub restoration_wall_secs: Number,
284}
285
286pub struct ReportBuilder {
290 detail: ReportDetail,
291 started_at: SystemTime,
292 started_unix_nanos: i128,
293 pub input: InputDescriptor,
294 pub problem: ProblemInfo,
295 pub solution: SolutionInfo,
296 pub stats: StatisticsInfo,
297 pub iterations: Vec<IterRecord>,
298 pub linear_solver: Option<LinearSolverSummaryInfo>,
299}
300
301impl ReportBuilder {
302 pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
303 let now = SystemTime::now();
304 let nanos = now
305 .duration_since(UNIX_EPOCH)
306 .map(|d| d.as_nanos() as i128)
307 .unwrap_or(0);
308 Self {
309 detail,
310 started_at: now,
311 started_unix_nanos: nanos,
312 input,
313 problem: ProblemInfo {
314 n_variables: 0,
315 n_constraints: 0,
316 n_objectives: 0,
317 minimize: true,
318 nnz_jac_g: None,
319 nnz_h_lag: None,
320 },
321 solution: SolutionInfo {
322 status: ApplicationReturnStatus::InternalError,
323 solve_result_num: 500,
324 objective: 0.0,
328 x: Vec::new(),
329 lambda: Vec::new(),
330 suffixes: Vec::new(),
331 },
332 stats: empty_stats(),
333 iterations: Vec::new(),
334 linear_solver: None,
335 }
336 }
337
338 pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
341 self.linear_solver = Some(summary.into());
342 }
343
344 pub fn ingest_stats(&mut self, src: &SolveStatistics) {
347 self.stats = StatisticsInfo {
348 iteration_count: src.iteration_count,
349 final_objective: src.final_objective,
350 final_scaled_objective: src.final_scaled_objective,
351 final_dual_inf: src.final_dual_inf,
352 final_constr_viol: src.final_constr_viol,
353 final_compl: src.final_compl,
354 final_kkt_error: src.final_kkt_error,
355 num_obj_evals: src.num_obj_evals,
356 num_constr_evals: src.num_constr_evals,
357 num_obj_grad_evals: src.num_obj_grad_evals,
358 num_constr_jac_evals: src.num_constr_jac_evals,
359 num_hess_evals: src.num_hess_evals,
360 total_wallclock_time_secs: src.total_wallclock_time_secs,
361 restoration_calls: src.restoration_calls,
362 restoration_inner_iters: src.restoration_inner_iters,
363 restoration_outer_iters: src.restoration_outer_iters,
364 restoration_wall_secs: src.restoration_wall_secs,
365 };
366 if matches!(self.detail, ReportDetail::Full) {
367 self.iterations = src.iterations.clone();
368 }
369 }
370
371 pub fn finish(self) -> SolveReport {
372 let elapsed = self
373 .started_at
374 .elapsed()
375 .map(|d| d.as_secs_f64())
376 .unwrap_or(0.0);
377 let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
378 let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
379
380 SolveReport {
381 schema: "pounce.solve-report/v1".to_string(),
382 fair_metadata: FairMetadata {
383 result_id,
384 created_at_iso,
385 created_at_unix_nanos: self.started_unix_nanos,
386 elapsed_seconds: elapsed,
387 solver: SolverIdentity {
388 name: "pounce".to_string(),
389 version: env!("CARGO_PKG_VERSION").to_string(),
390 git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
391 target_triple: TARGET_TRIPLE.to_string(),
392 },
393 license: "EPL-2.0".to_string(),
394 input: self.input,
395 },
396 problem: self.problem,
397 solution: self.solution,
398 statistics: self.stats,
399 iterations: self.iterations,
400 linear_solver: self.linear_solver,
401 }
402 }
403}
404
405const TARGET_TRIPLE: &str = match option_env!("POUNCE_TARGET_TRIPLE") {
413 Some(t) => t,
414 None => "unknown",
415};
416
417fn empty_stats() -> StatisticsInfo {
418 StatisticsInfo {
424 iteration_count: 0,
425 final_objective: 0.0,
426 final_scaled_objective: 0.0,
427 final_dual_inf: 0.0,
428 final_constr_viol: 0.0,
429 final_compl: 0.0,
430 final_kkt_error: 0.0,
431 num_obj_evals: 0,
432 num_constr_evals: 0,
433 num_obj_grad_evals: 0,
434 num_constr_jac_evals: 0,
435 num_hess_evals: 0,
436 total_wallclock_time_secs: 0.0,
437 restoration_calls: 0,
438 restoration_inner_iters: 0,
439 restoration_outer_iters: 0,
440 restoration_wall_secs: 0.0,
441 }
442}
443
444pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
456 use ApplicationReturnStatus::*;
457 match status {
458 SolveSucceeded => 0,
459 SolvedToAcceptableLevel => 100,
460 FeasiblePointFound => 100,
461 InfeasibleProblemDetected => 200,
462 DivergingIterates => 300,
463 SearchDirectionBecomesTooSmall => 400,
464 MaximumIterationsExceeded => 400,
465 MaximumCpuTimeExceeded => 400,
466 MaximumWallTimeExceeded => 400,
467 UserRequestedStop => 502,
468 RestorationFailed => 500,
469 ErrorInStepComputation => 500,
470 InvalidNumberDetected => 500,
471 InternalError => 500,
472 UnrecoverableException => 500,
473 NonIpoptExceptionThrown => 500,
474 InsufficientMemory => 503,
475 InvalidProblemDefinition => 504,
476 InvalidOption => 504,
477 NotEnoughDegreesOfFreedom => 504,
478 }
479}
480
481pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
484 let s = serde_json::to_string_pretty(report)
485 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
486 std::fs::write(path, &s)?;
487 Ok(s.len())
488}
489
490fn unix_nanos_to_iso(nanos: i128) -> String {
498 let total_secs = nanos.div_euclid(1_000_000_000) as i64;
499 let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
500 let millis = frac_nanos / 1_000_000;
501
502 let days = total_secs.div_euclid(86_400);
503 let secs_of_day = total_secs.rem_euclid(86_400);
504 let hh = (secs_of_day / 3600) as i32;
505 let mm = ((secs_of_day % 3600) / 60) as i32;
506 let ss = (secs_of_day % 60) as i32;
507
508 let z: i64 = days + 719468;
520 let era = if z >= 0 { z } else { z - 146096 } / 146097;
521 let doe = (z - era * 146097) as i64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let mut y = yoe + era * 400;
524 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;
527 let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
528 if m <= 2 {
529 y += 1;
530 }
531
532 format!(
533 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
534 y, m, d, hh, mm, ss, millis
535 )
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn iso_formatter_matches_known_epochs() {
544 assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
546 assert_eq!(
548 unix_nanos_to_iso(946_684_800_000_000_000),
549 "2000-01-01T00:00:00.000Z",
550 );
551 let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
557 assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
558 }
559
560 #[test]
561 fn target_triple_resolves_to_real_triple_not_unknown() {
562 assert_ne!(
568 TARGET_TRIPLE, "unknown",
569 "build.rs should re-export the build target triple"
570 );
571 assert!(
573 TARGET_TRIPLE.matches('-').count() >= 2,
574 "unexpected target triple: {TARGET_TRIPLE:?}"
575 );
576
577 let b = ReportBuilder::new(
579 ReportDetail::Summary,
580 InputDescriptor::NlFile {
581 path: PathBuf::from("/tmp/foo.nl"),
582 size_bytes: None,
583 },
584 );
585 let report = b.finish();
586 assert_eq!(report.fair_metadata.solver.target_triple, TARGET_TRIPLE);
587 assert_ne!(report.fair_metadata.solver.target_triple, "unknown");
588 }
589
590 #[test]
591 fn report_serializes_round_trip() {
592 let mut b = ReportBuilder::new(
593 ReportDetail::Summary,
594 InputDescriptor::NlFile {
595 path: PathBuf::from("/tmp/foo.nl"),
596 size_bytes: Some(123),
597 },
598 );
599 b.problem.n_variables = 5;
600 b.problem.n_constraints = 4;
601 b.solution.status = ApplicationReturnStatus::SolveSucceeded;
602 b.solution.solve_result_num = 0;
603 b.solution.objective = 0.55;
604 b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
605 b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
606 b.stats.iteration_count = 9;
607
608 let report = b.finish();
609 let json = serde_json::to_string_pretty(&report).expect("serialize");
610 let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
611 assert_eq!(back.schema, "pounce.solve-report/v1");
612 assert_eq!(back.problem.n_variables, 5);
613 assert_eq!(back.solution.x.len(), 5);
614 assert!(matches!(
615 back.solution.status,
616 ApplicationReturnStatus::SolveSucceeded,
617 ));
618 }
619
620 #[test]
621 fn summary_detail_omits_iterations_block() {
622 let mut b = ReportBuilder::new(
623 ReportDetail::Summary,
624 InputDescriptor::Builtin {
625 name: "rosenbrock".into(),
626 },
627 );
628 let mut stats = SolveStatistics::default();
629 stats.iterations.push(IterRecord {
630 iter: 0,
631 objective: 1.0,
632 ..IterRecord::default()
633 });
634 b.ingest_stats(&stats);
635 let r = b.finish();
636 assert!(
637 r.iterations.is_empty(),
638 "Summary detail should drop iter history; got {} rows",
639 r.iterations.len()
640 );
641 let json = serde_json::to_string(&r).unwrap();
643 assert!(!json.contains("\"iterations\":"), "json: {json}");
644 }
645
646 #[test]
647 fn full_detail_includes_iteration_rows() {
648 let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
649 let mut stats = SolveStatistics::default();
650 stats.iterations.push(IterRecord {
651 iter: 0,
652 objective: 1.0,
653 inf_pr: 0.5,
654 ..IterRecord::default()
655 });
656 stats.iterations.push(IterRecord {
657 iter: 1,
658 objective: 0.5,
659 inf_pr: 0.1,
660 ..IterRecord::default()
661 });
662 b.ingest_stats(&stats);
663 let r = b.finish();
664 assert_eq!(r.iterations.len(), 2);
665 assert_eq!(r.iterations[0].iter, 0);
666 assert_eq!(r.iterations[1].iter, 1);
667 }
668
669 #[test]
670 fn detail_parser_accepts_known_values() {
671 assert_eq!(
672 ReportDetail::parse("summary").unwrap(),
673 ReportDetail::Summary
674 );
675 assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
676 assert!(ReportDetail::parse("verbose").is_err());
677 }
678
679 #[test]
680 fn diverging_iterates_maps_to_unbounded_range() {
681 use ApplicationReturnStatus::*;
682 assert_eq!(status_to_solve_result_num(DivergingIterates), 300);
687
688 assert_eq!(status_to_solve_result_num(SolveSucceeded), 0);
691 assert_eq!(status_to_solve_result_num(InfeasibleProblemDetected), 200);
692 assert_eq!(
693 status_to_solve_result_num(MaximumIterationsExceeded),
694 400,
695 "iteration limit stays in the 400 range",
696 );
697 assert_eq!(
698 status_to_solve_result_num(SearchDirectionBecomesTooSmall),
699 400,
700 );
701 assert_eq!(status_to_solve_result_num(RestorationFailed), 500);
702 }
703
704 #[test]
705 fn result_id_is_unique_and_time_ordered() {
706 let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
707 std::thread::sleep(std::time::Duration::from_millis(2));
708 let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
709 assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
710 assert!(
711 b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
712 "second result_id should sort after first"
713 );
714 }
715}