use std::path::Path;
use serde::{Deserialize, Serialize};
use super::error::OutputError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestChecksum {
pub algorithm: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestMpiInfo {
pub world_size: u32,
pub ranks_participated: u32,
}
impl Default for ManifestMpiInfo {
fn default() -> Self {
Self {
world_size: 1,
ranks_participated: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestScenarios {
pub total: u32,
pub completed: u32,
pub failed: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationManifest {
pub version: String,
pub status: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub scenarios: ManifestScenarios,
pub partitions_written: Vec<String>,
pub checksum: Option<ManifestChecksum>,
pub mpi_info: ManifestMpiInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestIterations {
pub max_iterations: Option<u32>,
pub completed: u32,
pub converged_at: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestConvergence {
pub achieved: bool,
pub final_gap_percent: Option<f64>,
pub termination_reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestCuts {
pub total_generated: u64,
pub total_active: u64,
pub peak_active: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingManifest {
pub version: String,
pub status: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub iterations: ManifestIterations,
pub convergence: ManifestConvergence,
pub cuts: ManifestCuts,
pub checksum: Option<ManifestChecksum>,
pub mpi_info: ManifestMpiInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataRunInfo {
pub run_id: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub duration_seconds: Option<f64>,
pub cobre_version: String,
pub solver: Option<String>,
pub solver_version: Option<String>,
pub hostname: Option<String>,
pub user: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataConfigSnapshot {
pub seed: Option<i64>,
pub forward_passes: Option<u32>,
pub stopping_mode: String,
pub policy_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataProblemDimensions {
pub num_stages: u32,
pub num_hydros: u32,
pub num_thermals: u32,
pub num_buses: u32,
pub num_lines: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataPerformanceSummary {
pub total_lp_solves: Option<u64>,
pub avg_lp_time_us: Option<f64>,
pub median_lp_time_us: Option<f64>,
pub p99_lp_time_us: Option<f64>,
pub peak_memory_mb: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataDataIntegrity {
pub input_hash: Option<String>,
pub config_hash: Option<String>,
pub policy_hash: Option<String>,
pub convergence_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataEnvironment {
pub mpi_implementation: Option<String>,
pub mpi_version: Option<String>,
pub num_ranks: Option<u32>,
pub cpus_per_rank: Option<u32>,
pub memory_per_rank_gb: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingMetadata {
pub version: String,
pub run_info: MetadataRunInfo,
pub configuration_snapshot: MetadataConfigSnapshot,
pub problem_dimensions: MetadataProblemDimensions,
pub performance_summary: Option<MetadataPerformanceSummary>,
pub data_integrity: Option<MetadataDataIntegrity>,
pub environment: MetadataEnvironment,
}
pub fn write_simulation_manifest(
path: &Path,
manifest: &SimulationManifest,
) -> Result<(), OutputError> {
write_json_atomic(path, manifest, "simulation")
}
pub fn write_training_manifest(
path: &Path,
manifest: &TrainingManifest,
) -> Result<(), OutputError> {
write_json_atomic(path, manifest, "training")
}
pub fn write_metadata(path: &Path, metadata: &TrainingMetadata) -> Result<(), OutputError> {
write_json_atomic(path, metadata, "metadata")
}
fn write_json_atomic<T: Serialize>(
path: &Path,
value: &T,
manifest_type: &str,
) -> Result<(), OutputError> {
let json = serde_json::to_string_pretty(value).map_err(|e| OutputError::ManifestError {
manifest_type: manifest_type.to_string(),
message: e.to_string(),
})?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json).map_err(|e| OutputError::io(&tmp, e))?;
std::fs::rename(&tmp, path).map_err(|e| OutputError::io(path, e))?;
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::float_cmp,
clippy::cast_possible_truncation
)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_simulation_manifest() -> SimulationManifest {
SimulationManifest {
version: "2.0.0".to_string(),
status: "complete".to_string(),
started_at: Some("2026-01-17T10:00:00Z".to_string()),
completed_at: Some("2026-01-17T10:15:00Z".to_string()),
scenarios: ManifestScenarios {
total: 100,
completed: 100,
failed: 0,
},
partitions_written: vec!["scenario_id=0/".to_string(), "scenario_id=1/".to_string()],
checksum: None,
mpi_info: ManifestMpiInfo::default(),
}
}
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: 10,
converged_at: Some(10),
},
convergence: ManifestConvergence {
achieved: true,
final_gap_percent: Some(0.45),
termination_reason: "bound_stalling".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_training_metadata() -> TrainingMetadata {
TrainingMetadata {
version: "2.0.0".to_string(),
run_info: MetadataRunInfo {
run_id: "not-implemented".to_string(),
started_at: Some("2026-01-17T08:00:00Z".to_string()),
completed_at: Some("2026-01-17T12:30:00Z".to_string()),
duration_seconds: Some(16_200.0),
cobre_version: env!("CARGO_PKG_VERSION").to_string(),
solver: Some("highs".to_string()),
solver_version: None,
hostname: None,
user: None,
},
configuration_snapshot: MetadataConfigSnapshot {
seed: Some(42),
forward_passes: Some(192),
stopping_mode: "any".to_string(),
policy_mode: "fresh".to_string(),
},
problem_dimensions: MetadataProblemDimensions {
num_stages: 12,
num_hydros: 160,
num_thermals: 200,
num_buses: 5,
num_lines: 8,
},
performance_summary: None,
data_integrity: None,
environment: MetadataEnvironment {
mpi_implementation: None,
mpi_version: None,
num_ranks: None,
cpus_per_rank: None,
memory_per_rank_gb: None,
},
}
}
#[test]
fn simulation_manifest_roundtrip() {
let original = make_simulation_manifest();
let json = serde_json::to_string_pretty(&original).unwrap();
let decoded: SimulationManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, original.version);
assert_eq!(decoded.status, original.status);
assert_eq!(decoded.scenarios.total, original.scenarios.total);
assert_eq!(decoded.scenarios.completed, original.scenarios.completed);
assert_eq!(decoded.scenarios.failed, original.scenarios.failed);
assert_eq!(
decoded.partitions_written.len(),
original.partitions_written.len()
);
assert!(decoded.checksum.is_none());
assert_eq!(decoded.mpi_info.world_size, original.mpi_info.world_size);
assert_eq!(
decoded.mpi_info.ranks_participated,
original.mpi_info.ranks_participated
);
}
#[test]
fn training_manifest_roundtrip() {
let original = make_training_manifest();
let json = serde_json::to_string_pretty(&original).unwrap();
let decoded: TrainingManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.version, original.version);
assert_eq!(decoded.status, original.status);
assert_eq!(decoded.iterations.completed, original.iterations.completed);
assert_eq!(
decoded.iterations.max_iterations,
original.iterations.max_iterations
);
assert_eq!(
decoded.iterations.converged_at,
original.iterations.converged_at
);
assert_eq!(decoded.convergence.achieved, original.convergence.achieved);
assert_eq!(
decoded.convergence.final_gap_percent,
original.convergence.final_gap_percent
);
assert_eq!(
decoded.convergence.termination_reason,
original.convergence.termination_reason
);
assert_eq!(decoded.cuts.total_generated, original.cuts.total_generated);
assert_eq!(decoded.cuts.total_active, original.cuts.total_active);
assert_eq!(decoded.cuts.peak_active, original.cuts.peak_active);
assert!(decoded.checksum.is_none());
}
#[test]
fn training_metadata_serialization() {
let metadata = make_training_metadata();
let json = serde_json::to_string_pretty(&metadata).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
value["run_info"].is_object(),
"run_info must be a JSON object"
);
assert!(
value["run_info"]["cobre_version"].is_string(),
"run_info.cobre_version must be a string"
);
assert!(
value["configuration_snapshot"].is_object(),
"configuration_snapshot must be a JSON object"
);
assert!(
value["problem_dimensions"].is_object(),
"problem_dimensions must be a JSON object"
);
assert!(
value["performance_summary"].is_null(),
"performance_summary must be null in minimal viable version"
);
assert!(
value["data_integrity"].is_null(),
"data_integrity must be null in minimal viable version"
);
assert!(
value["environment"].is_object(),
"environment must be a JSON object"
);
}
#[test]
fn write_simulation_manifest_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("_manifest.json");
let manifest = make_simulation_manifest();
write_simulation_manifest(&path, &manifest).expect("write must succeed");
assert!(path.exists(), "manifest file must exist after write");
let content = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value =
serde_json::from_str(&content).expect("file must contain valid JSON");
}
#[test]
fn write_training_manifest_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("_manifest.json");
let manifest = make_training_manifest();
write_training_manifest(&path, &manifest).expect("write must succeed");
assert!(path.exists(), "manifest file must exist after write");
let content = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value =
serde_json::from_str(&content).expect("file must contain valid JSON");
}
#[test]
fn write_metadata_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_training_metadata();
write_metadata(&path, &metadata).expect("write must succeed");
assert!(path.exists(), "metadata file must exist after write");
let content = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value =
serde_json::from_str(&content).expect("file must contain valid JSON");
}
#[test]
fn write_training_manifest_fields_survive_write_read_cycle() {
let dir = tempdir().unwrap();
let path = dir.path().join("_manifest.json");
let manifest = TrainingManifest {
version: "2.0.0".to_string(),
status: "complete".to_string(),
started_at: None,
completed_at: None,
iterations: ManifestIterations {
max_iterations: Some(100),
completed: 10,
converged_at: Some(10),
},
convergence: ManifestConvergence {
achieved: true,
final_gap_percent: None,
termination_reason: "iteration_limit".to_string(),
},
cuts: ManifestCuts {
total_generated: 200,
total_active: 80,
peak_active: 95,
},
checksum: None,
mpi_info: ManifestMpiInfo::default(),
};
write_training_manifest(&path, &manifest).expect("write must succeed");
let content = std::fs::read_to_string(&path).unwrap();
let decoded: TrainingManifest = serde_json::from_str(&content).unwrap();
assert_eq!(
decoded.iterations.completed, 10,
"iterations.completed must round-trip correctly"
);
assert!(
decoded.convergence.achieved,
"convergence.achieved must round-trip correctly"
);
}
#[test]
fn write_simulation_manifest_json_field_values() {
let dir = tempdir().unwrap();
let path = dir.path().join("_manifest.json");
let manifest = SimulationManifest {
version: "2.0.0".to_string(),
status: "complete".to_string(),
started_at: None,
completed_at: None,
scenarios: ManifestScenarios {
total: 100,
completed: 100,
failed: 0,
},
partitions_written: vec![],
checksum: None,
mpi_info: ManifestMpiInfo::default(),
};
write_simulation_manifest(&path, &manifest).expect("write must succeed");
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(
value["scenarios"]["total"].as_u64(),
Some(100),
"$.scenarios.total must equal 100"
);
assert_eq!(
value["scenarios"]["completed"].as_u64(),
Some(100),
"$.scenarios.completed must equal 100"
);
}
#[test]
fn write_metadata_cobre_version_matches_cargo_pkg_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_training_metadata();
write_metadata(&path, &metadata).expect("write must succeed");
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
let version = value["run_info"]["cobre_version"]
.as_str()
.expect("run_info.cobre_version must be a string");
assert_eq!(
version,
env!("CARGO_PKG_VERSION"),
"cobre_version must equal CARGO_PKG_VERSION"
);
}
#[test]
fn write_manifest_missing_parent_directory_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent_subdir").join("_manifest.json");
let manifest = make_simulation_manifest();
let result = write_simulation_manifest(&path, &manifest);
assert!(
result.is_err(),
"write must fail when parent directory does not exist"
);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"error must be IoError when parent directory is missing, got: {result:?}"
);
}
#[test]
fn write_training_manifest_missing_parent_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent_subdir").join("_manifest.json");
let manifest = make_training_manifest();
let result = write_training_manifest(&path, &manifest);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"error must be IoError when parent directory is missing"
);
}
#[test]
fn write_metadata_missing_parent_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent_subdir").join("metadata.json");
let metadata = make_training_metadata();
let result = write_metadata(&path, &metadata);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"error must be IoError when parent directory is missing"
);
}
#[test]
fn write_manifest_atomic_no_tmp_remains() {
let dir = tempdir().unwrap();
let path = dir.path().join("_manifest.json");
let manifest = make_simulation_manifest();
write_simulation_manifest(&path, &manifest).expect("write must succeed");
let tmp = path.with_extension("json.tmp");
assert!(
!tmp.exists(),
"no .tmp file must remain after a successful write, but found: {}",
tmp.display()
);
assert!(path.exists(), "the target file must exist");
}
#[test]
fn manifest_null_checksum_serializes() {
let manifest = SimulationManifest {
version: "2.0.0".to_string(),
status: "complete".to_string(),
started_at: None,
completed_at: None,
scenarios: ManifestScenarios {
total: 0,
completed: 0,
failed: 0,
},
partitions_written: vec![],
checksum: None,
mpi_info: ManifestMpiInfo::default(),
};
let json = serde_json::to_string_pretty(&manifest).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
value["checksum"].is_null(),
"checksum: None must serialize as null in JSON, got: {}",
value["checksum"]
);
}
#[test]
fn training_manifest_null_checksum_serializes() {
let manifest = make_training_manifest();
let json = serde_json::to_string_pretty(&manifest).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
value["checksum"].is_null(),
"checksum: None must serialize as null in training manifest"
);
}
}