use std::path::PathBuf;
use clap::Args;
use console::Term;
use cobre_io::{
ConvergenceSummary, OutputError, SimulationManifest, TrainingManifest,
read_convergence_summary, read_simulation_manifest, read_training_manifest,
};
use crate::{
error::CliError,
summary::{
SimulationSummary, TrainingSummary, print_simulation_summary, print_training_summary,
},
};
#[derive(Debug, Args)]
#[command(about = "Display the post-run summary from a completed output directory")]
pub struct SummaryArgs {
pub output_dir: PathBuf,
}
pub fn execute(args: SummaryArgs) -> Result<(), CliError> {
let output_dir = args.output_dir;
if !output_dir.try_exists().map_err(|e| CliError::Io {
source: e,
context: "output directory".to_string(),
})? {
return Err(CliError::Io {
source: std::io::Error::new(std::io::ErrorKind::NotFound, "output directory not found"),
context: output_dir.display().to_string(),
});
}
let training_manifest_path = output_dir.join("training/_manifest.json");
let manifest: TrainingManifest =
read_training_manifest(&training_manifest_path).map_err(CliError::from)?;
let convergence_path = output_dir.join("training/convergence.parquet");
let convergence = read_convergence_summary(&convergence_path)
.unwrap_or_else(|_| convergence_fallback(&manifest));
let simulation_manifest_path = output_dir.join("simulation/_manifest.json");
let simulation: Option<SimulationManifest> =
read_optional_simulation_manifest(&simulation_manifest_path)?;
let training_summary = build_training_summary(&manifest, &convergence);
let stderr = Term::stderr();
print_training_summary(&stderr, &training_summary);
if let Some(sim) = simulation {
let simulation_summary = build_simulation_summary(&sim);
let _ = stderr.write_line("");
print_simulation_summary(&stderr, &simulation_summary);
}
Ok(())
}
fn convergence_fallback(manifest: &TrainingManifest) -> ConvergenceSummary {
ConvergenceSummary {
total_lp_solves: 0,
total_time_ms: 0,
final_lower_bound: 0.0,
final_upper_bound_mean: 0.0,
final_upper_bound_std: 0.0,
final_gap_percent: manifest.convergence.final_gap_percent,
}
}
fn build_training_summary(
manifest: &TrainingManifest,
convergence: &ConvergenceSummary,
) -> TrainingSummary {
TrainingSummary {
iterations: u64::from(manifest.iterations.completed),
converged: manifest.convergence.achieved,
converged_at: manifest.iterations.converged_at.map(u64::from),
reason: manifest.convergence.termination_reason.clone(),
lower_bound: convergence.final_lower_bound,
upper_bound: convergence.final_upper_bound_mean,
upper_bound_std: convergence.final_upper_bound_std,
gap_percent: convergence.final_gap_percent.unwrap_or(0.0),
total_cuts_active: manifest.cuts.total_active,
total_cuts_generated: manifest.cuts.total_generated,
total_lp_solves: convergence.total_lp_solves,
total_time_ms: convergence.total_time_ms,
total_first_try: 0,
total_retried: 0,
total_failed: 0,
total_solve_time_seconds: 0.0,
total_basis_offered: 0,
total_basis_rejections: 0,
total_simplex_iterations: 0,
}
}
fn build_simulation_summary(manifest: &SimulationManifest) -> SimulationSummary {
SimulationSummary {
n_scenarios: manifest.scenarios.total,
completed: manifest.scenarios.completed,
failed: manifest.scenarios.failed,
total_time_ms: 0,
total_lp_solves: 0,
total_first_try: 0,
total_retried: 0,
total_failed_solves: 0,
total_solve_time_seconds: 0.0,
total_basis_offered: 0,
total_basis_rejections: 0,
total_simplex_iterations: 0,
}
}
fn read_optional_simulation_manifest(
path: &std::path::Path,
) -> Result<Option<SimulationManifest>, CliError> {
match read_simulation_manifest(path) {
Ok(manifest) => Ok(Some(manifest)),
Err(OutputError::IoError { source, .. })
if source.kind() == std::io::ErrorKind::NotFound =>
{
Ok(None)
}
Err(e) => Err(CliError::from(e)),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::path::PathBuf;
use cobre_io::{
ConvergenceSummary, ManifestConvergence, ManifestCuts, ManifestIterations, ManifestMpiInfo,
TrainingManifest,
};
use super::{SummaryArgs, build_training_summary, convergence_fallback};
fn make_training_manifest() -> TrainingManifest {
TrainingManifest {
version: "2.0.0".to_string(),
status: "complete".to_string(),
started_at: Some("2026-01-17T08:00:00Z".to_string()),
completed_at: Some("2026-01-17T12:30:00Z".to_string()),
iterations: ManifestIterations {
max_iterations: Some(100),
completed: 42,
converged_at: Some(42),
},
convergence: ManifestConvergence {
achieved: true,
final_gap_percent: Some(0.45),
termination_reason: "gap_tolerance".to_string(),
},
cuts: ManifestCuts {
total_generated: 1_250_000,
total_active: 980_000,
peak_active: 1_100_000,
},
checksum: None,
mpi_info: ManifestMpiInfo::default(),
}
}
fn make_convergence_summary() -> ConvergenceSummary {
ConvergenceSummary {
total_lp_solves: 84_000,
total_time_ms: 12_345,
final_lower_bound: 48_500.0,
final_upper_bound_mean: 49_000.0,
final_upper_bound_std: 250.0,
final_gap_percent: Some(1.03),
}
}
#[test]
fn summary_args_parses_output_dir() {
let args = SummaryArgs {
output_dir: PathBuf::from("/tmp/out"),
};
assert_eq!(args.output_dir, PathBuf::from("/tmp/out"));
}
#[test]
fn construct_training_summary_from_manifest() {
let manifest = make_training_manifest();
let convergence = make_convergence_summary();
let summary = build_training_summary(&manifest, &convergence);
assert_eq!(
summary.iterations, 42,
"iterations must equal manifest.iterations.completed"
);
assert!(
summary.converged,
"converged must match manifest.convergence.achieved"
);
assert_eq!(
summary.converged_at,
Some(42),
"converged_at must be mapped from manifest.iterations.converged_at"
);
assert_eq!(
summary.reason, "gap_tolerance",
"reason must equal manifest.convergence.termination_reason"
);
assert!(
(summary.lower_bound - 48_500.0).abs() < f64::EPSILON,
"lower_bound must come from convergence data"
);
assert!(
(summary.upper_bound - 49_000.0).abs() < f64::EPSILON,
"upper_bound must come from convergence data"
);
assert!(
(summary.upper_bound_std - 250.0).abs() < f64::EPSILON,
"upper_bound_std must come from convergence data"
);
assert!(
(summary.gap_percent - 1.03).abs() < 1e-9,
"gap_percent must come from convergence data"
);
assert_eq!(
summary.total_cuts_active, 980_000,
"total_cuts_active must come from manifest.cuts"
);
assert_eq!(
summary.total_cuts_generated, 1_250_000,
"total_cuts_generated must come from manifest.cuts"
);
assert_eq!(
summary.total_lp_solves, 84_000,
"total_lp_solves must come from convergence data"
);
assert_eq!(
summary.total_time_ms, 12_345,
"total_time_ms must come from convergence data"
);
}
#[test]
fn convergence_fallback_uses_manifest_gap_percent() {
let manifest = make_training_manifest();
let fallback = convergence_fallback(&manifest);
assert_eq!(
fallback.total_lp_solves, 0,
"fallback total_lp_solves must be 0"
);
assert_eq!(
fallback.total_time_ms, 0,
"fallback total_time_ms must be 0"
);
assert_eq!(
fallback.final_gap_percent,
Some(0.45),
"fallback gap_percent must come from manifest.convergence.final_gap_percent"
);
}
#[test]
fn convergence_fallback_gap_none_when_manifest_has_no_gap() {
let mut manifest = make_training_manifest();
manifest.convergence.final_gap_percent = None;
let fallback = convergence_fallback(&manifest);
assert!(
fallback.final_gap_percent.is_none(),
"fallback gap_percent must be None when manifest has no gap"
);
}
#[test]
fn build_training_summary_gap_defaults_to_zero_when_none() {
let manifest = make_training_manifest();
let convergence = ConvergenceSummary {
final_gap_percent: None,
..make_convergence_summary()
};
let summary = build_training_summary(&manifest, &convergence);
assert!(
summary.gap_percent.abs() < f64::EPSILON,
"gap_percent must default to 0.0 when convergence summary has None"
);
}
#[test]
fn build_training_summary_converged_at_none_when_manifest_has_none() {
let mut manifest = make_training_manifest();
manifest.iterations.converged_at = None;
manifest.convergence.achieved = false;
let convergence = make_convergence_summary();
let summary = build_training_summary(&manifest, &convergence);
assert!(
summary.converged_at.is_none(),
"converged_at must be None when manifest has no converged_at"
);
assert!(!summary.converged, "converged must be false");
}
}