use std::path::{Path, PathBuf};
use std::sync::Arc;
use arrow::array::{Float64Builder, Int32Builder, Int64Builder, RecordBatch};
use parquet::arrow::ArrowWriter;
use parquet::file::properties::WriterProperties;
use super::{IterationRecord, TrainingOutput};
use crate::output::error::OutputError;
use crate::output::parquet_config::ParquetWriterConfig;
use crate::output::schemas::{convergence_schema, iteration_timing_schema};
pub struct TrainingParquetWriter {
output_dir: PathBuf,
config: ParquetWriterConfig,
}
impl TrainingParquetWriter {
pub fn new(output_dir: &Path, config: &ParquetWriterConfig) -> Result<Self, OutputError> {
let training_dir = output_dir.join("training");
let timing_dir = output_dir.join("training/timing");
if !training_dir.exists() {
return Err(OutputError::io(
&training_dir,
std::io::Error::new(
std::io::ErrorKind::NotFound,
"training/ directory does not exist",
),
));
}
if !timing_dir.exists() {
return Err(OutputError::io(
&timing_dir,
std::io::Error::new(
std::io::ErrorKind::NotFound,
"training/timing/ directory does not exist",
),
));
}
Ok(Self {
output_dir: output_dir.to_path_buf(),
config: config.clone(),
})
}
pub fn write(&self, training_output: &TrainingOutput) -> Result<(), OutputError> {
let records = &training_output.convergence_records;
let convergence_batch = build_convergence_batch(records)?;
let convergence_path = self.output_dir.join("training/convergence.parquet");
write_parquet(&convergence_path, &convergence_batch, &self.config)?;
let timing_batch = build_iteration_timing_batch(records)?;
let timing_path = self.output_dir.join("training/timing/iterations.parquet");
write_parquet(&timing_path, &timing_batch, &self.config)?;
Ok(())
}
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn build_convergence_batch(records: &[IterationRecord]) -> Result<RecordBatch, OutputError> {
let schema = Arc::new(convergence_schema());
let n = records.len();
let mut iteration = Int32Builder::with_capacity(n);
let mut lower_bound = Float64Builder::with_capacity(n);
let mut upper_bound_mean = Float64Builder::with_capacity(n);
let mut upper_bound_std = Float64Builder::with_capacity(n);
let mut gap_percent = Float64Builder::with_capacity(n);
let mut cuts_added = Int32Builder::with_capacity(n);
let mut cuts_removed = Int32Builder::with_capacity(n);
let mut cuts_active = Int64Builder::with_capacity(n);
let mut time_forward_ms = Int64Builder::with_capacity(n);
let mut time_backward_ms = Int64Builder::with_capacity(n);
let mut time_total_ms = Int64Builder::with_capacity(n);
let mut forward_passes = Int32Builder::with_capacity(n);
let mut lp_solves = Int64Builder::with_capacity(n);
for rec in records {
iteration.append_value(rec.iteration as i32);
lower_bound.append_value(rec.lower_bound);
upper_bound_mean.append_value(rec.upper_bound_mean);
upper_bound_std.append_value(rec.upper_bound_std);
gap_percent.append_option(rec.gap_percent);
cuts_added.append_value(rec.cuts_added as i32);
cuts_removed.append_value(rec.cuts_removed as i32);
cuts_active.append_value(i64::from(rec.cuts_active));
time_forward_ms.append_value(rec.time_forward_ms as i64);
time_backward_ms.append_value(rec.time_backward_ms as i64);
time_total_ms.append_value(rec.time_total_ms as i64);
forward_passes.append_value(rec.forward_passes as i32);
lp_solves.append_value(i64::from(rec.lp_solves));
}
RecordBatch::try_new(
schema,
vec![
Arc::new(iteration.finish()),
Arc::new(lower_bound.finish()),
Arc::new(upper_bound_mean.finish()),
Arc::new(upper_bound_std.finish()),
Arc::new(gap_percent.finish()),
Arc::new(cuts_added.finish()),
Arc::new(cuts_removed.finish()),
Arc::new(cuts_active.finish()),
Arc::new(time_forward_ms.finish()),
Arc::new(time_backward_ms.finish()),
Arc::new(time_total_ms.finish()),
Arc::new(forward_passes.finish()),
Arc::new(lp_solves.finish()),
],
)
.map_err(|e| OutputError::serialization("convergence", e.to_string()))
}
#[allow(clippy::cast_possible_wrap)]
fn build_iteration_timing_batch(records: &[IterationRecord]) -> Result<RecordBatch, OutputError> {
let schema = Arc::new(iteration_timing_schema());
let n = records.len();
let mut iteration = Int32Builder::with_capacity(n);
let mut forward_solve_ms = Int64Builder::with_capacity(n);
let mut forward_sample_ms = Int64Builder::with_capacity(n);
let mut backward_solve_ms = Int64Builder::with_capacity(n);
let mut backward_cut_ms = Int64Builder::with_capacity(n);
let mut cut_selection_ms = Int64Builder::with_capacity(n);
let mut mpi_allreduce_ms = Int64Builder::with_capacity(n);
let mut mpi_broadcast_ms = Int64Builder::with_capacity(n);
let mut io_write_ms = Int64Builder::with_capacity(n);
let mut state_exchange_ms = Int64Builder::with_capacity(n);
let mut cut_batch_build_ms = Int64Builder::with_capacity(n);
let mut rayon_overhead_ms = Int64Builder::with_capacity(n);
let mut overhead_ms = Int64Builder::with_capacity(n);
for rec in records {
iteration.append_value(rec.iteration as i32);
forward_solve_ms.append_value(rec.time_forward_solve_ms as i64);
forward_sample_ms.append_value(rec.time_forward_sample_ms as i64);
backward_solve_ms.append_value(rec.time_backward_solve_ms as i64);
backward_cut_ms.append_value(rec.time_backward_cut_ms as i64);
cut_selection_ms.append_value(rec.time_cut_selection_ms as i64);
mpi_allreduce_ms.append_value(rec.time_mpi_allreduce_ms as i64);
mpi_broadcast_ms.append_value(rec.time_mpi_broadcast_ms as i64);
io_write_ms.append_value(rec.time_io_write_ms as i64);
state_exchange_ms.append_value(rec.time_state_exchange_ms as i64);
cut_batch_build_ms.append_value(rec.time_cut_batch_build_ms as i64);
rayon_overhead_ms.append_value(rec.time_rayon_overhead_ms as i64);
overhead_ms.append_value(rec.time_overhead_ms as i64);
}
RecordBatch::try_new(
schema,
vec![
Arc::new(iteration.finish()),
Arc::new(forward_solve_ms.finish()),
Arc::new(forward_sample_ms.finish()),
Arc::new(backward_solve_ms.finish()),
Arc::new(backward_cut_ms.finish()),
Arc::new(cut_selection_ms.finish()),
Arc::new(mpi_allreduce_ms.finish()),
Arc::new(mpi_broadcast_ms.finish()),
Arc::new(io_write_ms.finish()),
Arc::new(state_exchange_ms.finish()),
Arc::new(cut_batch_build_ms.finish()),
Arc::new(rayon_overhead_ms.finish()),
Arc::new(overhead_ms.finish()),
],
)
.map_err(|e| OutputError::serialization("iteration_timing", e.to_string()))
}
pub fn write_cut_selection_records(
output_dir: &Path,
records: &[super::CutSelectionRecord],
config: &ParquetWriterConfig,
) -> Result<(), OutputError> {
if records.is_empty() {
return Ok(());
}
let dir = output_dir.join("training/cut_selection");
std::fs::create_dir_all(&dir).map_err(|e| OutputError::io(&dir, e))?;
let schema = Arc::new(super::schemas::cut_selection_schema());
let n = records.len();
let mut iteration_builder = Int32Builder::with_capacity(n);
let mut stage_builder = Int32Builder::with_capacity(n);
let mut populated_builder = Int32Builder::with_capacity(n);
let mut active_before_builder = Int32Builder::with_capacity(n);
let mut deactivated_builder = Int32Builder::with_capacity(n);
let mut active_after_builder = Int32Builder::with_capacity(n);
let mut selection_time_builder = Float64Builder::with_capacity(n);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for r in records {
iteration_builder.append_value(r.iteration as i32);
stage_builder.append_value(r.stage as i32);
populated_builder.append_value(r.cuts_populated as i32);
active_before_builder.append_value(r.cuts_active_before as i32);
deactivated_builder.append_value(r.cuts_deactivated as i32);
active_after_builder.append_value(r.cuts_active_after as i32);
selection_time_builder.append_value(r.selection_time_ms);
}
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(iteration_builder.finish()),
Arc::new(stage_builder.finish()),
Arc::new(populated_builder.finish()),
Arc::new(active_before_builder.finish()),
Arc::new(deactivated_builder.finish()),
Arc::new(active_after_builder.finish()),
Arc::new(selection_time_builder.finish()),
],
)
.map_err(|e| OutputError::serialization("cut_selection", e.to_string()))?;
write_parquet(&dir.join("iterations.parquet"), &batch, config)
}
fn write_parquet(
path: &Path,
batch: &RecordBatch,
config: &ParquetWriterConfig,
) -> Result<(), OutputError> {
let tmp_path = path.with_extension(path.extension().map_or_else(
|| "tmp".to_string(),
|ext| format!("{}.tmp", ext.to_string_lossy()),
));
let props = WriterProperties::builder()
.set_compression(config.compression)
.set_max_row_group_row_count(Some(config.row_group_size))
.set_dictionary_enabled(config.dictionary_encoding)
.build();
let file = std::fs::File::create(&tmp_path).map_err(|e| OutputError::io(&tmp_path, e))?;
let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))
.map_err(|e| OutputError::serialization("parquet_writer", e.to_string()))?;
writer
.write(batch)
.map_err(|e| OutputError::serialization("parquet_writer", e.to_string()))?;
writer
.close()
.map_err(|e| OutputError::serialization("parquet_writer", e.to_string()))?;
std::fs::rename(&tmp_path, path).map_err(|e| OutputError::io(path, e))?;
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::float_cmp
)]
mod tests {
use super::*;
use crate::output::{CutStatistics, TrainingOutput};
fn make_record(iteration: u32, gap: Option<f64>) -> IterationRecord {
IterationRecord {
iteration,
lower_bound: f64::from(iteration) * 10.0,
upper_bound_mean: f64::from(iteration) * 11.0,
upper_bound_std: 0.5,
gap_percent: gap,
cuts_added: 5,
cuts_removed: 1,
cuts_active: 4,
time_forward_ms: 100,
time_backward_ms: 200,
time_total_ms: 300,
time_forward_solve_ms: 100,
time_forward_sample_ms: 0,
time_backward_solve_ms: 200,
time_backward_cut_ms: 0,
time_cut_selection_ms: 0,
time_mpi_allreduce_ms: 0,
time_mpi_broadcast_ms: 0,
time_io_write_ms: 0,
time_state_exchange_ms: 0,
time_cut_batch_build_ms: 0,
time_rayon_overhead_ms: 0,
time_overhead_ms: 0,
forward_passes: 4,
lp_solves: 40,
solve_time_ms: 0.0,
}
}
fn make_training_output(records: Vec<IterationRecord>) -> TrainingOutput {
TrainingOutput {
convergence_records: records,
final_lower_bound: 99.5,
final_upper_bound: Some(101.0),
final_gap_percent: Some(1.51),
iterations_completed: 0,
converged: true,
termination_reason: "gap tolerance reached".to_string(),
total_time_ms: 5_000,
cut_stats: CutStatistics {
total_generated: 200,
total_active: 80,
peak_active: 95,
},
cut_selection_records: vec![],
}
}
#[test]
fn convergence_batch_from_empty_records() {
let batch = build_convergence_batch(&[]).expect("empty batch must succeed");
assert_eq!(batch.num_rows(), 0, "empty records yield 0 rows");
assert_eq!(batch.num_columns(), 13, "convergence schema has 13 columns");
}
#[test]
fn convergence_batch_field_count_and_types() {
let records: Vec<IterationRecord> = (1..=3).map(|i| make_record(i, Some(5.0))).collect();
let batch = build_convergence_batch(&records).expect("batch must be built");
assert_eq!(batch.num_rows(), 3);
assert_eq!(batch.num_columns(), 13);
let expected_schema = convergence_schema();
assert_eq!(
batch.schema().fields(),
expected_schema.fields(),
"schema must match convergence_schema()"
);
}
#[test]
fn convergence_batch_nullable_columns() {
let records = vec![
make_record(1, Some(10.0)),
make_record(2, Some(5.0)),
make_record(3, None), ];
let batch = build_convergence_batch(&records).expect("batch must be built");
let gap_col = batch
.column_by_name("gap_percent")
.expect("gap_percent column must exist");
assert!(!gap_col.is_null(0), "row 0: Some(10.0) must not be null");
assert!(!gap_col.is_null(1), "row 1: Some(5.0) must not be null");
assert!(gap_col.is_null(2), "row 2: None must be null");
}
#[test]
fn iteration_timing_batch_field_count() {
let records: Vec<IterationRecord> = (1..=3).map(|i| make_record(i, Some(1.0))).collect();
let batch = build_iteration_timing_batch(&records).expect("timing batch must be built");
assert_eq!(batch.num_rows(), 3, "3 records yield 3 rows");
assert_eq!(
batch.num_columns(),
13,
"iteration_timing schema has 13 columns"
);
let expected_schema = iteration_timing_schema();
assert_eq!(
batch.schema().fields(),
expected_schema.fields(),
"schema must match iteration_timing_schema()"
);
}
#[test]
fn write_convergence_parquet_roundtrip() {
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let records: Vec<IterationRecord> = (1..=5).map(|i| make_record(i, Some(1.0))).collect();
let batch = build_convergence_batch(&records).expect("batch must be built");
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp.path().join("convergence.parquet");
let config = ParquetWriterConfig::default();
write_parquet(&path, &batch, &config).expect("write must succeed");
assert!(path.exists(), "convergence.parquet must exist after write");
let file = std::fs::File::open(&path).expect("file must open");
let builder =
ParquetRecordBatchReaderBuilder::try_new(file).expect("builder must be created");
let mut reader = builder.build().expect("reader must be built");
let read_batch = reader
.next()
.expect("must have at least one batch")
.expect("batch must be Ok");
assert_eq!(read_batch.num_rows(), 5, "must have 5 rows");
let expected_schema = convergence_schema();
assert_eq!(
read_batch.schema().fields(),
expected_schema.fields(),
"schema must match convergence_schema()"
);
let iteration_col = read_batch
.column_by_name("iteration")
.expect("iteration column must exist");
let iteration_arr = iteration_col
.as_any()
.downcast_ref::<arrow::array::Int32Array>()
.expect("iteration must be Int32Array");
let iteration_values: Vec<i32> = (0..5).map(|i| iteration_arr.value(i)).collect();
assert_eq!(iteration_values, vec![1, 2, 3, 4, 5]);
let lb_col = read_batch
.column_by_name("lower_bound")
.expect("lower_bound column must exist");
let lb_arr = lb_col
.as_any()
.downcast_ref::<arrow::array::Float64Array>()
.expect("lower_bound must be Float64Array");
for (i, rec) in records.iter().enumerate() {
assert_eq!(
lb_arr.value(i),
rec.lower_bound,
"lower_bound mismatch at row {i}"
);
}
}
#[test]
fn write_convergence_parquet_atomic_rename() {
let records: Vec<IterationRecord> = (1..=2).map(|i| make_record(i, None)).collect();
let batch = build_convergence_batch(&records).expect("batch must be built");
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let path = tmp.path().join("convergence.parquet");
let config = ParquetWriterConfig::default();
write_parquet(&path, &batch, &config).expect("write must succeed");
let tmp_path = path.with_extension("parquet.tmp");
assert!(
!tmp_path.exists(),
".tmp file must not exist after successful atomic rename"
);
assert!(path.exists(), "final file must exist");
}
#[test]
fn writer_fails_if_training_dir_missing() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let config = ParquetWriterConfig::default();
let result = TrainingParquetWriter::new(tmp.path(), &config);
assert!(result.is_err(), "new() must fail when training/ is missing");
}
#[test]
fn writer_fails_if_timing_dir_missing() {
let tmp = tempfile::tempdir().expect("tempdir must succeed");
let config = ParquetWriterConfig::default();
std::fs::create_dir_all(tmp.path().join("training")).unwrap();
let result = TrainingParquetWriter::new(tmp.path(), &config);
assert!(
result.is_err(),
"new() must fail when training/timing/ is missing"
);
}
#[test]
fn writer_writes_empty_training_output() {
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let tmp = tempfile::tempdir().expect("tempdir must succeed");
std::fs::create_dir_all(tmp.path().join("training/timing")).unwrap();
let config = ParquetWriterConfig::default();
let writer = TrainingParquetWriter::new(tmp.path(), &config).expect("new must succeed");
let training = make_training_output(vec![]);
writer.write(&training).expect("write must succeed");
let conv_path = tmp.path().join("training/convergence.parquet");
assert!(conv_path.exists(), "convergence.parquet must exist");
let timing_path = tmp.path().join("training/timing/iterations.parquet");
assert!(timing_path.exists(), "iterations.parquet must exist");
let file = std::fs::File::open(&conv_path).expect("file must open");
let builder = ParquetRecordBatchReaderBuilder::try_new(file).expect("builder created");
let schema = builder.schema().clone();
let reader = builder.build().expect("reader built");
let total_rows: usize = reader
.map(|b| b.expect("batch must be Ok").num_rows())
.sum();
assert_eq!(total_rows, 0, "empty training output must produce 0 rows");
let expected_schema = convergence_schema();
assert_eq!(
schema.fields(),
expected_schema.fields(),
"schema must match convergence_schema()"
);
}
#[test]
fn writer_writes_five_records() {
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let tmp = tempfile::tempdir().expect("tempdir must succeed");
std::fs::create_dir_all(tmp.path().join("training/timing")).unwrap();
let config = ParquetWriterConfig::default();
let records: Vec<IterationRecord> = (1..=5).map(|i| make_record(i, Some(1.0))).collect();
let training = make_training_output(records);
let writer = TrainingParquetWriter::new(tmp.path(), &config).expect("new must succeed");
writer.write(&training).expect("write must succeed");
let conv_path = tmp.path().join("training/convergence.parquet");
let file = std::fs::File::open(&conv_path).expect("file must open");
let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
.expect("builder")
.build()
.expect("reader");
let batch = reader.next().expect("must have rows").expect("batch Ok");
assert_eq!(batch.num_rows(), 5);
assert_eq!(batch.num_columns(), 13);
let timing_path = tmp.path().join("training/timing/iterations.parquet");
let file = std::fs::File::open(&timing_path).expect("file must open");
let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
.expect("builder")
.build()
.expect("reader");
let batch = reader.next().expect("must have rows").expect("batch Ok");
assert_eq!(batch.num_rows(), 5);
assert_eq!(batch.num_columns(), 13);
}
#[test]
fn writer_gap_percent_null_at_correct_row() {
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let tmp = tempfile::tempdir().expect("tempdir must succeed");
std::fs::create_dir_all(tmp.path().join("training/timing")).unwrap();
let config = ParquetWriterConfig::default();
let records = vec![
make_record(1, Some(10.0)),
make_record(2, Some(5.0)),
make_record(3, None), make_record(4, Some(2.0)),
make_record(5, Some(1.0)),
];
let training = make_training_output(records);
let writer = TrainingParquetWriter::new(tmp.path(), &config).expect("new must succeed");
writer.write(&training).expect("write must succeed");
let conv_path = tmp.path().join("training/convergence.parquet");
let file = std::fs::File::open(&conv_path).expect("file must open");
let mut reader = ParquetRecordBatchReaderBuilder::try_new(file)
.expect("builder")
.build()
.expect("reader");
let batch = reader.next().expect("must have rows").expect("batch Ok");
let gap_col = batch
.column_by_name("gap_percent")
.expect("gap_percent column must exist");
assert!(!gap_col.is_null(0), "row 0: Some(10.0) must not be null");
assert!(!gap_col.is_null(1), "row 1: Some(5.0) must not be null");
assert!(gap_col.is_null(2), "row 2: None must be null");
assert!(!gap_col.is_null(3), "row 3: Some(2.0) must not be null");
assert!(!gap_col.is_null(4), "row 4: Some(1.0) must not be null");
}
#[test]
fn write_cut_selection_empty_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let config = ParquetWriterConfig::default();
write_cut_selection_records(tmp.path(), &[], &config).unwrap();
assert!(
!tmp.path()
.join("training/cut_selection/iterations.parquet")
.exists()
);
}
#[test]
fn write_cut_selection_roundtrip() {
use super::super::CutSelectionRecord;
let tmp = tempfile::tempdir().unwrap();
let config = ParquetWriterConfig::default();
let records = vec![
CutSelectionRecord {
iteration: 3,
stage: 0,
cuts_populated: 10,
cuts_active_before: 10,
cuts_deactivated: 0,
cuts_active_after: 10,
selection_time_ms: 0.0,
},
CutSelectionRecord {
iteration: 3,
stage: 1,
cuts_populated: 8,
cuts_active_before: 8,
cuts_deactivated: 2,
cuts_active_after: 6,
selection_time_ms: 1.5,
},
];
write_cut_selection_records(tmp.path(), &records, &config).unwrap();
let path = tmp.path().join("training/cut_selection/iterations.parquet");
assert!(path.exists());
let file = std::fs::File::open(&path).unwrap();
let reader = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
.unwrap()
.build()
.unwrap();
let batch: RecordBatch = reader.into_iter().next().unwrap().unwrap();
assert_eq!(batch.num_rows(), 2);
assert_eq!(batch.num_columns(), 7);
}
}