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
41pub mod console;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ReportDetail {
52 Summary,
55 Full,
58}
59
60impl ReportDetail {
61 pub fn parse(s: &str) -> Result<Self, String> {
62 match s.to_ascii_lowercase().as_str() {
63 "summary" => Ok(ReportDetail::Summary),
64 "full" => Ok(ReportDetail::Full),
65 other => Err(format!(
66 "unknown --json-detail '{other}' (expected: summary | full)"
67 )),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SolveReport {
76 pub schema: String,
79 pub fair_metadata: FairMetadata,
81 pub problem: ProblemInfo,
83 pub solution: SolutionInfo,
85 pub statistics: StatisticsInfo,
87 #[serde(skip_serializing_if = "Vec::is_empty", default)]
90 pub iterations: Vec<IterRecord>,
91 #[serde(skip_serializing_if = "Option::is_none", default)]
99 pub linear_solver: Option<LinearSolverSummaryInfo>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LinearSolverSummaryInfo {
108 pub solver_name: String,
109 pub n_factors: u64,
110 pub n_pattern_reuse: u64,
111 pub n_pattern_changes: u64,
112 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub max_fill_ratio: Option<f64>,
114 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub min_abs_pivot: Option<f64>,
116 #[serde(skip_serializing_if = "Option::is_none", default)]
117 pub max_abs_pivot: Option<f64>,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub last_inertia: Option<(usize, usize, usize)>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
122 pub last_nnz_a: Option<usize>,
123 #[serde(skip_serializing_if = "Option::is_none", default)]
124 pub last_nnz_l: Option<usize>,
125}
126
127impl From<LinearSolverSummary> for LinearSolverSummaryInfo {
128 fn from(s: LinearSolverSummary) -> Self {
129 Self {
130 solver_name: s.solver_name,
131 n_factors: s.n_factors,
132 n_pattern_reuse: s.n_pattern_reuse,
133 n_pattern_changes: s.n_pattern_changes,
134 max_fill_ratio: s.max_fill_ratio,
135 min_abs_pivot: s.min_abs_pivot,
136 max_abs_pivot: s.max_abs_pivot,
137 last_inertia: s.last_inertia,
138 last_nnz_a: s.last_nnz_a,
139 last_nnz_l: s.last_nnz_l,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct FairMetadata {
155 pub result_id: String,
159 pub created_at_iso: String,
161 pub created_at_unix_nanos: i128,
165 pub elapsed_seconds: Number,
168 pub solver: SolverIdentity,
170 pub license: String,
172 pub input: InputDescriptor,
175 #[serde(skip_serializing_if = "Vec::is_empty", default)]
189 pub environment: Vec<EnvOverride>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct EnvOverride {
196 pub name: String,
198 pub value: String,
200}
201
202const SOLVE_AFFECTING_ENV_VARS: &[&str] = &[
208 "POUNCE_FERAL_ORDERING",
209 "POUNCE_FERAL_SCALING",
210 "POUNCE_FERAL_PIVTOL",
211 "POUNCE_FERAL_REFINE",
212 "POUNCE_FERAL_CASCADE_BREAK",
213 "POUNCE_FERAL_FMA",
214 "POUNCE_FERAL_SINGULAR_PIVOT_FLOOR",
215 "POUNCE_FERAL_MIN_PAR_FLOPS",
216 "FERAL_PIVTOL",
220 "FERAL_PARALLEL",
221];
222
223pub fn capture_solve_env_overrides() -> Vec<EnvOverride> {
228 SOLVE_AFFECTING_ENV_VARS
229 .iter()
230 .filter_map(|&name| {
231 std::env::var(name).ok().map(|value| EnvOverride {
232 name: name.to_string(),
233 value,
234 })
235 })
236 .collect()
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SolverIdentity {
241 pub name: String,
242 pub version: String,
243 #[serde(skip_serializing_if = "Option::is_none")]
248 pub git_commit: Option<String>,
249 pub target_triple: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "kind", rename_all = "kebab-case")]
256pub enum InputDescriptor {
257 NlFile {
258 path: PathBuf,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 size_bytes: Option<u64>,
261 },
262 CbfFile {
265 path: PathBuf,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 size_bytes: Option<u64>,
268 },
269 Builtin {
270 name: String,
271 },
272 TnlpDirect,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct ProblemInfo {
277 pub n_variables: Index,
278 pub n_constraints: Index,
279 pub n_objectives: Index,
280 pub minimize: bool,
281 #[serde(skip_serializing_if = "Option::is_none")]
284 pub nnz_jac_g: Option<Index>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub nnz_h_lag: Option<Index>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SolutionInfo {
292 pub status: ApplicationReturnStatus,
295 pub solve_result_num: i32,
297 pub objective: Number,
300 #[serde(skip_serializing_if = "Vec::is_empty", default)]
303 pub x: Vec<Number>,
304 #[serde(skip_serializing_if = "Vec::is_empty", default)]
307 pub lambda: Vec<Number>,
308 #[serde(skip_serializing_if = "Vec::is_empty", default)]
313 pub suffixes: Vec<SolutionSuffix>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct SolutionSuffix {
318 pub name: String,
319 pub target: String,
321 pub kind: String,
323 #[serde(skip_serializing_if = "Vec::is_empty", default)]
327 pub values: Vec<Number>,
328 #[serde(skip_serializing_if = "Vec::is_empty", default)]
329 pub int_values: Vec<Index>,
330}
331
332fn uncomputed() -> Number {
334 Number::NAN
335}
336
337fn null_as_nan<'de, D>(de: D) -> Result<Number, D::Error>
346where
347 D: serde::Deserializer<'de>,
348{
349 Ok(Option::<Number>::deserialize(de)?.unwrap_or_else(uncomputed))
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct StatisticsInfo {
356 pub iteration_count: Index,
357 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
358 pub final_objective: Number,
359 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
360 pub final_scaled_objective: Number,
361 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
362 pub final_dual_inf: Number,
363 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
364 pub final_constr_viol: Number,
365 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
366 pub final_compl: Number,
367 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
368 pub final_kkt_error: Number,
369 #[serde(default = "uncomputed", deserialize_with = "null_as_nan")]
374 pub final_kkt_error_above_noise: Number,
375 pub num_obj_evals: Index,
376 pub num_constr_evals: Index,
377 pub num_obj_grad_evals: Index,
378 pub num_constr_jac_evals: Index,
379 pub num_hess_evals: Index,
380 pub total_wallclock_time_secs: Number,
381 pub restoration_calls: Index,
382 pub restoration_inner_iters: Index,
383 pub restoration_outer_iters: Index,
384 pub restoration_wall_secs: Number,
385}
386
387pub struct ReportBuilder {
391 detail: ReportDetail,
392 started_at: SystemTime,
393 started_unix_nanos: i128,
394 pub input: InputDescriptor,
395 pub problem: ProblemInfo,
396 pub solution: SolutionInfo,
397 pub stats: StatisticsInfo,
398 pub iterations: Vec<IterRecord>,
399 pub linear_solver: Option<LinearSolverSummaryInfo>,
400}
401
402impl ReportBuilder {
403 pub fn new(detail: ReportDetail, input: InputDescriptor) -> Self {
404 let now = SystemTime::now();
405 let nanos = now
406 .duration_since(UNIX_EPOCH)
407 .map(|d| d.as_nanos() as i128)
408 .unwrap_or(0);
409 Self {
410 detail,
411 started_at: now,
412 started_unix_nanos: nanos,
413 input,
414 problem: ProblemInfo {
415 n_variables: 0,
416 n_constraints: 0,
417 n_objectives: 0,
418 minimize: true,
419 nnz_jac_g: None,
420 nnz_h_lag: None,
421 },
422 solution: SolutionInfo {
423 status: ApplicationReturnStatus::InternalError,
424 solve_result_num: 500,
425 objective: 0.0,
429 x: Vec::new(),
430 lambda: Vec::new(),
431 suffixes: Vec::new(),
432 },
433 stats: empty_stats(),
434 iterations: Vec::new(),
435 linear_solver: None,
436 }
437 }
438
439 pub fn set_linear_solver_summary(&mut self, summary: LinearSolverSummary) {
442 self.linear_solver = Some(summary.into());
443 }
444
445 pub fn ingest_stats(&mut self, src: &SolveStatistics) {
448 self.stats = StatisticsInfo {
449 iteration_count: src.iteration_count,
450 final_objective: src.final_objective,
451 final_scaled_objective: src.final_scaled_objective,
452 final_dual_inf: src.final_dual_inf,
453 final_constr_viol: src.final_constr_viol,
454 final_compl: src.final_compl,
455 final_kkt_error: src.final_kkt_error,
456 final_kkt_error_above_noise: src.final_kkt_error_above_noise,
457 num_obj_evals: src.num_obj_evals,
458 num_constr_evals: src.num_constr_evals,
459 num_obj_grad_evals: src.num_obj_grad_evals,
460 num_constr_jac_evals: src.num_constr_jac_evals,
461 num_hess_evals: src.num_hess_evals,
462 total_wallclock_time_secs: src.total_wallclock_time_secs,
463 restoration_calls: src.restoration_calls,
464 restoration_inner_iters: src.restoration_inner_iters,
465 restoration_outer_iters: src.restoration_outer_iters,
466 restoration_wall_secs: src.restoration_wall_secs,
467 };
468 if matches!(self.detail, ReportDetail::Full) {
469 self.iterations = src.iterations.clone();
470 }
471 }
472
473 pub fn finish(self) -> SolveReport {
474 let elapsed = self
475 .started_at
476 .elapsed()
477 .map(|d| d.as_secs_f64())
478 .unwrap_or(0.0);
479 let result_id = format!("{}-{}", self.started_unix_nanos, std::process::id());
480 let created_at_iso = unix_nanos_to_iso(self.started_unix_nanos);
481
482 SolveReport {
483 schema: "pounce.solve-report/v1".to_string(),
484 fair_metadata: FairMetadata {
485 result_id,
486 created_at_iso,
487 created_at_unix_nanos: self.started_unix_nanos,
488 elapsed_seconds: elapsed,
489 solver: SolverIdentity {
490 name: "pounce".to_string(),
491 version: env!("CARGO_PKG_VERSION").to_string(),
492 git_commit: option_env!("POUNCE_GIT_COMMIT").map(String::from),
493 target_triple: TARGET_TRIPLE.to_string(),
494 },
495 license: "EPL-2.0".to_string(),
496 input: self.input,
497 environment: capture_solve_env_overrides(),
498 },
499 problem: self.problem,
500 solution: self.solution,
501 statistics: self.stats,
502 iterations: self.iterations,
503 linear_solver: self.linear_solver,
504 }
505 }
506}
507
508const TARGET_TRIPLE: &str = match option_env!("POUNCE_TARGET_TRIPLE") {
516 Some(t) => t,
517 None => "unknown",
518};
519
520fn empty_stats() -> StatisticsInfo {
521 StatisticsInfo {
527 iteration_count: 0,
528 final_objective: 0.0,
529 final_scaled_objective: 0.0,
530 final_dual_inf: 0.0,
531 final_constr_viol: 0.0,
532 final_compl: 0.0,
533 final_kkt_error: 0.0,
534 final_kkt_error_above_noise: 0.0,
535 num_obj_evals: 0,
536 num_constr_evals: 0,
537 num_obj_grad_evals: 0,
538 num_constr_jac_evals: 0,
539 num_hess_evals: 0,
540 total_wallclock_time_secs: 0.0,
541 restoration_calls: 0,
542 restoration_inner_iters: 0,
543 restoration_outer_iters: 0,
544 restoration_wall_secs: 0.0,
545 }
546}
547
548pub fn status_to_solve_result_num(status: ApplicationReturnStatus) -> i32 {
560 use ApplicationReturnStatus::*;
561 match status {
562 SolveSucceeded => 0,
563 SolvedToAcceptableLevel => 100,
564 FeasiblePointFound => 100,
565 InfeasibleProblemDetected => 200,
566 DivergingIterates => 300,
567 SearchDirectionBecomesTooSmall => 400,
568 MaximumIterationsExceeded => 400,
569 MaximumCpuTimeExceeded => 400,
570 MaximumWallTimeExceeded => 400,
571 UserRequestedStop => 502,
572 RestorationFailed => 500,
573 ErrorInStepComputation => 500,
574 InvalidNumberDetected => 500,
575 InternalError => 500,
576 UnrecoverableException => 500,
577 NonIpoptExceptionThrown => 500,
578 InsufficientMemory => 503,
579 InvalidProblemDefinition => 504,
580 InvalidOption => 504,
581 NotEnoughDegreesOfFreedom => 504,
582 }
583}
584
585pub fn write_report_file(path: &Path, report: &SolveReport) -> std::io::Result<usize> {
588 let s = serde_json::to_string_pretty(report)
589 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
590 std::fs::write(path, &s)?;
591 Ok(s.len())
592}
593
594fn unix_nanos_to_iso(nanos: i128) -> String {
602 let total_secs = nanos.div_euclid(1_000_000_000) as i64;
603 let frac_nanos = nanos.rem_euclid(1_000_000_000) as i64;
604 let millis = frac_nanos / 1_000_000;
605
606 let days = total_secs.div_euclid(86_400);
607 let secs_of_day = total_secs.rem_euclid(86_400);
608 let hh = (secs_of_day / 3600) as i32;
609 let mm = ((secs_of_day % 3600) / 60) as i32;
610 let ss = (secs_of_day % 60) as i32;
611
612 let z: i64 = days + 719468;
624 let era = if z >= 0 { z } else { z - 146096 } / 146097;
625 let doe = (z - era * 146097) as i64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let mut y = yoe + era * 400;
628 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;
631 let m = if mp < 10 { mp + 3 } else { mp - 9 } as i32;
632 if m <= 2 {
633 y += 1;
634 }
635
636 format!(
637 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
638 y, m, d, hh, mm, ss, millis
639 )
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn iso_formatter_matches_known_epochs() {
648 assert_eq!(unix_nanos_to_iso(0), "1970-01-01T00:00:00.000Z");
650 assert_eq!(
652 unix_nanos_to_iso(946_684_800_000_000_000),
653 "2000-01-01T00:00:00.000Z",
654 );
655 let s = unix_nanos_to_iso(1_709_210_096_789_000_000);
661 assert_eq!(s, "2024-02-29T12:34:56.789Z", "got: {s}");
662 }
663
664 #[test]
665 fn target_triple_resolves_to_real_triple_not_unknown() {
666 assert_ne!(
672 TARGET_TRIPLE, "unknown",
673 "build.rs should re-export the build target triple"
674 );
675 assert!(
677 TARGET_TRIPLE.matches('-').count() >= 2,
678 "unexpected target triple: {TARGET_TRIPLE:?}"
679 );
680
681 let b = ReportBuilder::new(
683 ReportDetail::Summary,
684 InputDescriptor::NlFile {
685 path: PathBuf::from("/tmp/foo.nl"),
686 size_bytes: None,
687 },
688 );
689 let report = b.finish();
690 assert_eq!(report.fair_metadata.solver.target_triple, TARGET_TRIPLE);
691 assert_ne!(report.fair_metadata.solver.target_triple, "unknown");
692 }
693
694 #[test]
695 fn report_serializes_round_trip() {
696 let mut b = ReportBuilder::new(
697 ReportDetail::Summary,
698 InputDescriptor::NlFile {
699 path: PathBuf::from("/tmp/foo.nl"),
700 size_bytes: Some(123),
701 },
702 );
703 b.problem.n_variables = 5;
704 b.problem.n_constraints = 4;
705 b.solution.status = ApplicationReturnStatus::SolveSucceeded;
706 b.solution.solve_result_num = 0;
707 b.solution.objective = 0.55;
708 b.solution.x = vec![0.63, 0.39, 0.02, 5.0, 1.0];
709 b.solution.lambda = vec![-0.16, -0.29, -0.16, 0.18];
710 b.stats.iteration_count = 9;
711
712 let report = b.finish();
713 let json = serde_json::to_string_pretty(&report).expect("serialize");
714 let back: SolveReport = serde_json::from_str(&json).expect("deserialize");
715 assert_eq!(back.schema, "pounce.solve-report/v1");
716 assert_eq!(back.problem.n_variables, 5);
717 assert_eq!(back.solution.x.len(), 5);
718 assert!(matches!(
719 back.solution.status,
720 ApplicationReturnStatus::SolveSucceeded,
721 ));
722 }
723
724 #[test]
725 fn summary_detail_omits_iterations_block() {
726 let mut b = ReportBuilder::new(
727 ReportDetail::Summary,
728 InputDescriptor::Builtin {
729 name: "rosenbrock".into(),
730 },
731 );
732 let mut stats = SolveStatistics::default();
733 stats.iterations.push(IterRecord {
734 iter: 0,
735 objective: 1.0,
736 ..IterRecord::default()
737 });
738 b.ingest_stats(&stats);
739 let r = b.finish();
740 assert!(
741 r.iterations.is_empty(),
742 "Summary detail should drop iter history; got {} rows",
743 r.iterations.len()
744 );
745 let json = serde_json::to_string(&r).unwrap();
747 assert!(!json.contains("\"iterations\":"), "json: {json}");
748 }
749
750 #[test]
751 fn full_detail_includes_iteration_rows() {
752 let mut b = ReportBuilder::new(ReportDetail::Full, InputDescriptor::TnlpDirect);
753 let mut stats = SolveStatistics::default();
754 stats.iterations.push(IterRecord {
755 iter: 0,
756 objective: 1.0,
757 inf_pr: 0.5,
758 ..IterRecord::default()
759 });
760 stats.iterations.push(IterRecord {
761 iter: 1,
762 objective: 0.5,
763 inf_pr: 0.1,
764 ..IterRecord::default()
765 });
766 b.ingest_stats(&stats);
767 let r = b.finish();
768 assert_eq!(r.iterations.len(), 2);
769 assert_eq!(r.iterations[0].iter, 0);
770 assert_eq!(r.iterations[1].iter, 1);
771 }
772
773 #[test]
774 fn detail_parser_accepts_known_values() {
775 assert_eq!(
776 ReportDetail::parse("summary").unwrap(),
777 ReportDetail::Summary
778 );
779 assert_eq!(ReportDetail::parse("Full").unwrap(), ReportDetail::Full);
780 assert!(ReportDetail::parse("verbose").is_err());
781 }
782
783 #[test]
784 fn diverging_iterates_maps_to_unbounded_range() {
785 use ApplicationReturnStatus::*;
786 assert_eq!(status_to_solve_result_num(DivergingIterates), 300);
791
792 assert_eq!(status_to_solve_result_num(SolveSucceeded), 0);
795 assert_eq!(status_to_solve_result_num(InfeasibleProblemDetected), 200);
796 assert_eq!(
797 status_to_solve_result_num(MaximumIterationsExceeded),
798 400,
799 "iteration limit stays in the 400 range",
800 );
801 assert_eq!(
802 status_to_solve_result_num(SearchDirectionBecomesTooSmall),
803 400,
804 );
805 assert_eq!(status_to_solve_result_num(RestorationFailed), 500);
806 }
807
808 #[test]
809 fn result_id_is_unique_and_time_ordered() {
810 let a = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
811 std::thread::sleep(std::time::Duration::from_millis(2));
812 let b = ReportBuilder::new(ReportDetail::Summary, InputDescriptor::TnlpDirect).finish();
813 assert_ne!(a.fair_metadata.result_id, b.fair_metadata.result_id);
814 assert!(
815 b.fair_metadata.created_at_unix_nanos > a.fair_metadata.created_at_unix_nanos,
816 "second result_id should sort after first"
817 );
818 }
819}