Skip to main content

cobre_io/output/
mod.rs

1//! Output writers for simulation results and policy files.
2//!
3//! This module provides Hive-partitioned Parquet writers for simulation pipeline
4//! output and `FlatBuffers` policy writers.
5//!
6//! The top-level entry point is [`write_results`], which mirrors [`crate::load_case`]:
7//! it accepts aggregate result types and writes all output artifacts to the
8//! specified directory.
9
10use chrono::{Datelike, NaiveDate};
11
12pub(crate) mod atomic;
13pub mod convergence_reader;
14pub mod dictionary;
15pub mod error;
16pub mod fixed_delivery;
17pub mod generic_constraints_echo;
18pub mod hydro_models;
19pub mod manifest;
20pub mod parquet_config;
21pub mod policy;
22pub mod provenance;
23pub mod results_writer;
24pub mod scaling_report;
25pub(crate) mod schemas;
26pub mod simulation_writer;
27pub mod solver_stats_writer;
28pub mod stochastic;
29pub mod training_writer;
30
31pub use convergence_reader::{
32    ConvergenceSummary, read_convergence_summary, read_initial_gap_percent,
33};
34pub use dictionary::write_dictionaries;
35pub use error::OutputError;
36pub use fixed_delivery::{FixedDeliveryRow, write_fixed_delivery};
37pub use generic_constraints_echo::{GenericConstraintEchoRow, write_generic_constraint_echo};
38pub use hydro_models::{
39    read_hydro_model_summary, write_evaporation_models, write_fpha_deviation_points,
40    write_fpha_hyperplanes, write_hydro_model_summary,
41};
42pub use manifest::{
43    DeviationSummary, DeviationWorstEntry, DistributionInfo, HostLayout, MetadataBounds,
44    MetadataConfiguration, MetadataConvergence, MetadataCost, MetadataIterations,
45    MetadataProblemDimensions, MetadataRowPool, MetadataScenarios, MetadataSimulationSolveStats,
46    MetadataTrainingSolveStats, OutputContext, SetupTimings, SimulationMetadata, TrainingMetadata,
47    default_bounds, get_hostname, now_iso8601, read_simulation_metadata, read_training_metadata,
48    write_simulation_metadata, write_training_metadata,
49};
50pub use parquet_config::ParquetWriterConfig;
51pub use provenance::{read_provenance_report, write_provenance_report};
52pub use results_writer::{write_results, write_simulation_results, write_training_results};
53pub use scaling_report::write_scaling_report;
54pub use simulation_writer::SimulationParquetWriter;
55pub use solver_stats_writer::{SolverStatsRow, write_simulation_solver_stats, write_solver_stats};
56pub use stochastic::{
57    FittingReductionEntry, FittingReport, HydroFittingEntry, write_correlation_json,
58    write_fitting_report, write_inflow_annual_component, write_inflow_ar_coefficients,
59    write_inflow_seasonal_stats, write_load_seasonal_stats, write_noise_openings,
60};
61pub use training_writer::{TrainingParquetWriter, write_row_selection_records};
62
63/// Arrow `Date32`'s native representation (days since the Unix epoch,
64/// 1970-01-01) for one calendar date.
65pub(crate) fn date32_days(date: NaiveDate) -> i32 {
66    let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).map_or(0, |e| e.num_days_from_ce());
67    date.num_days_from_ce() - epoch
68}
69
70/// One row of convergence data for a single training iteration, written to
71/// `training/convergence.parquet`.
72///
73/// `time_*` fields whose doc names a column map to that column in
74/// `training/timing/iterations.parquet`; those tagged a sub-component of a pass
75/// nest under that pass's wall-clock total rather than adding to the top level.
76#[derive(Debug, Clone)]
77pub struct IterationRecord {
78    /// Sequential iteration number (1-based).
79    pub iteration: u32,
80
81    /// Lower bound on the optimal value at the end of this iteration.
82    pub lower_bound: f64,
83
84    /// Upper bound estimate for this iteration: the sample mean under a sampled
85    /// forward, the exact probability-weighted bound under an enumerated forward.
86    pub upper_bound: f64,
87
88    /// Standard deviation of the upper bound estimate across scenarios. Written
89    /// as NULL to `training/convergence.parquet` under an exact bound.
90    pub upper_bound_std: f64,
91
92    /// Relative gap between upper and lower bounds as a percentage, if defined.
93    ///
94    /// `None` when the lower bound is zero or negative (gap is ill-defined).
95    pub gap_percent: Option<f64>,
96
97    /// Number of rows added to the row pool during this iteration.
98    pub cuts_added: u32,
99
100    /// Number of rows removed from the row pool during this iteration.
101    pub cuts_removed: u32,
102
103    /// Total number of active rows in the pool after this iteration.
104    pub cuts_active: u32,
105
106    /// Wall-clock time spent in the forward pass for this iteration (ms).
107    pub time_forward_ms: u64,
108
109    /// Wall-clock time spent in the backward pass for this iteration (ms).
110    pub time_backward_ms: u64,
111
112    /// Total wall-clock time for this iteration (ms).
113    pub time_total_ms: u64,
114
115    /// Forward pass wall-clock time (ms) → `forward_wall_ms`.
116    pub time_forward_wall_ms: u64,
117
118    /// Backward pass wall-clock time (ms) → `backward_wall_ms`.
119    pub time_backward_wall_ms: u64,
120
121    /// Row-selection phase time (ms) → `cut_selection_ms`.
122    pub time_cut_selection_ms: u64,
123
124    /// MPI allreduce (forward bound synchronization) time (ms) → `mpi_allreduce_ms`.
125    pub time_mpi_allreduce_ms: u64,
126
127    /// Per-stage row-sync allgatherv time (ms) → `cut_sync_ms`. Backward sub-component.
128    pub time_cut_sync_ms: u64,
129
130    /// Lower bound evaluation time (ms) → `lower_bound_ms`.
131    pub time_lower_bound_ms: u64,
132
133    /// State-exchange (`allgatherv`) time (ms) → `state_exchange_ms`. Backward sub-component.
134    pub time_state_exchange_ms: u64,
135
136    /// Row-batch assembly time (ms) → `cut_batch_build_ms`. Backward sub-component.
137    pub time_cut_batch_build_ms: u64,
138
139    /// Backward thread-pool setup time (ms) → `bwd_setup_ms`. Backward sub-component.
140    pub time_bwd_setup_ms: u64,
141
142    /// Estimated backward worker load imbalance (ms) → `bwd_load_imbalance_ms`. Backward sub-component.
143    pub time_bwd_load_imbalance_ms: u64,
144
145    /// Backward scheduling/sync overhead (ms) → `bwd_scheduling_overhead_ms`. Backward sub-component.
146    pub time_bwd_scheduling_overhead_ms: u64,
147
148    /// Forward thread-pool setup time (ms) → `fwd_setup_ms`. Forward sub-component.
149    pub time_fwd_setup_ms: u64,
150
151    /// Estimated forward worker load imbalance (ms) → `fwd_load_imbalance_ms`. Forward sub-component.
152    pub time_fwd_load_imbalance_ms: u64,
153
154    /// Forward scheduling/sync overhead (ms) → `fwd_scheduling_overhead_ms`. Forward sub-component.
155    pub time_fwd_scheduling_overhead_ms: u64,
156
157    /// Residual time not attributed to any phase (ms) → `overhead_ms`. Computed as
158    /// `time_total_ms - (forward + backward + cut_selection + mpi_allreduce + lower_bound)`.
159    pub time_overhead_ms: u64,
160
161    /// Number of forward-pass scenarios solved in this iteration.
162    pub forward_passes: u32,
163
164    /// Total number of LP solves (across all stages and passes) in this iteration.
165    pub lp_solves: u32,
166
167    /// Cumulative LP solve wall-clock time for this iteration, in milliseconds.
168    pub solve_time_ms: f64,
169
170    /// Mean resident row count loaded per lazy-selection LP solve during this
171    /// iteration (reduced across ranks). `0.0` when no lazy selection ran. Maps
172    /// to `mean_rows_in_lp` in `training/convergence.parquet`; it reflects the
173    /// per-solve LP size the lazy selector actually carried, which (unlike the
174    /// pool-level active count) shrinks well below the generated total.
175    pub mean_rows_in_lp: f64,
176}
177
178/// Summary statistics for the row pool at the end of a training run.
179///
180/// Carried inside [`TrainingOutput`] and written to `training/timing/cut_stats.parquet`.
181#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
182pub struct RowPoolStatistics {
183    /// Total number of rows generated over the entire training run.
184    pub total_generated: u64,
185
186    /// Number of rows still active in the pool at the end of training.
187    pub total_active: u64,
188
189    /// Highest number of active rows observed at any point during training.
190    pub peak_active: u64,
191
192    /// Total rows currently active in the LP.
193    pub cuts_active: u64,
194
195    /// Sum, over every lazy-selection LP solve in the run, of the resident row
196    /// count loaded into that solve (reduced across ranks). With
197    /// [`Self::rows_in_lp_solve_count`] this gives the mean rows-in-LP per solve.
198    /// Zero when no lazy selection ran (the resident-subset solve path was never
199    /// taken), letting consumers distinguish "not applicable" from "zero rows".
200    pub rows_in_lp_total: u64,
201
202    /// Number of lazy-selection LP solves in the run (reduced across ranks); the
203    /// denominator for the mean rows-in-LP. Zero when no lazy selection ran.
204    pub rows_in_lp_solve_count: u64,
205
206    /// Largest resident row count loaded into any single lazy-selection LP solve
207    /// over the run (reduced across ranks). Zero when no lazy selection ran.
208    pub rows_in_lp_max: u64,
209
210    /// Rows loaded from a boundary policy rather than generated by this run;
211    /// a subset of [`Self::total_generated`]. Zero when no boundary policy loaded.
212    pub total_loaded: u64,
213}
214
215/// One row in `training/cut_selection/iterations.parquet`.
216///
217/// Represents per-stage row-selection statistics for a single iteration.
218/// Only populated when row selection is enabled.
219#[derive(Debug, Clone)]
220pub struct RowSelectionRecord {
221    /// Iteration number (1-based).
222    pub iteration: u32,
223    /// 0-based stage index.
224    pub stage: u32,
225    /// Total cuts ever generated at this stage.
226    pub cuts_populated: u32,
227    /// Active cuts before selection ran.
228    pub cuts_active_before: u32,
229    /// Cuts deactivated by selection at this stage.
230    pub cuts_deactivated: u32,
231    /// Number of cuts reactivated this iteration.
232    pub cuts_reactivated: u32,
233    /// Active cuts after selection.
234    pub cuts_active_after: u32,
235    /// Wall-clock time for selection at this stage, in milliseconds.
236    pub selection_time_ms: f64,
237    /// Cuts evicted by budget enforcement at this stage.
238    ///
239    /// `None` when budget enforcement is disabled (`max_active_per_stage` is absent).
240    pub budget_evicted: Option<u32>,
241    /// Active cuts after budget enforcement.
242    ///
243    /// `None` when budget enforcement is disabled.
244    pub active_after_budget: Option<u32>,
245}
246
247/// One row in `training/timing/iterations.parquet`.
248///
249/// The timing parquet stores multiple rows per iteration:
250///
251/// - One **rank-aggregated** row per `(iteration, rank)` carrying rank-only
252///   timing columns (`worker_id = None`). Per-worker slots are `0` on this row.
253/// - One **per-worker** row per `(iteration, rank, worker_id)` carrying
254///   per-worker slots (`forward_wall_ms`, `backward_wall_ms`, `bwd_setup_ms`,
255///   `fwd_setup_ms`, `lazy_scoring_ms`). Rank-only slots are `0` on these rows.
256///
257/// `SUM(col) GROUP BY iteration` across all rows recovers the
258/// single-row-per-iteration value for each of the 16 timing columns.
259#[derive(Debug, Clone)]
260pub struct WorkerTimingRecord {
261    /// Training iteration (1-based).
262    pub iteration: u32,
263    /// MPI rank that produced this row.
264    pub rank: i32,
265    /// Rayon worker index within the rank's pool, or `None` for rank-aggregated rows.
266    pub worker_id: Option<i32>,
267    /// Fixed-size timing payload matching the 16 timing columns of
268    /// `iteration_timing_schema()` (positions 3–18, after `iteration`, `rank`,
269    /// `worker_id`). Slot indices correspond to the `WORKER_TIMING_SLOT_*`
270    /// constants defined in `cobre-core`.
271    pub timings: [u64; 16],
272}
273
274/// Aggregate type carrying all training data needed for output writing.
275///
276/// Constructed by the solver after training completes and passed to
277/// [`write_results`]. All convergence records and summary statistics are
278/// held here so the writer can read them without contacting the solver.
279#[derive(Debug, Clone)]
280pub struct TrainingOutput {
281    /// Ordered convergence records — one entry per completed iteration.
282    pub convergence_records: Vec<IterationRecord>,
283
284    /// Lower bound value reported after the final iteration.
285    pub final_lower_bound: f64,
286
287    /// Upper bound value reported after the final iteration, if available.
288    ///
289    /// `None` when no upper-bound evaluation was performed.
290    pub final_upper_bound: Option<f64>,
291
292    /// Relative gap between final upper and lower bounds as a percentage.
293    ///
294    /// `None` when the lower bound is zero/negative or `final_upper_bound` is `None`.
295    pub final_gap_percent: Option<f64>,
296
297    /// Standard deviation of the final upper-bound estimate, if available.
298    ///
299    /// `None` when no upper-bound evaluation was performed or the bound is exact.
300    /// The value is carried separately in
301    /// [`final_upper_bound`](Self::final_upper_bound).
302    pub final_upper_bound_std: Option<f64>,
303
304    /// Upper-bound regime for the whole run: `"statistical"` (sampled forward) or
305    /// `"exact"` (enumerated forward). Mirrored into `training/convergence.parquet`
306    /// and `training/metadata.json`.
307    pub final_upper_bound_kind: String,
308
309    /// Number of iterations completed before the stopping condition was triggered.
310    pub iterations_completed: u32,
311
312    /// `true` when training converged within the configured tolerance.
313    pub converged: bool,
314
315    /// Human-readable description of the rule that terminated training.
316    pub termination_reason: String,
317
318    /// Total elapsed wall-clock time for the entire training run (ms).
319    pub total_time_ms: u64,
320
321    /// Summary row pool statistics for the run.
322    pub cut_stats: RowPoolStatistics,
323
324    /// Per-stage row-selection records for Parquet output.
325    ///
326    /// Empty when row selection is disabled. When non-empty, written to
327    /// `training/cut_selection/iterations.parquet`.
328    pub cut_selection_records: Vec<RowSelectionRecord>,
329
330    /// Per-worker timing records for `training/timing/iterations.parquet`.
331    ///
332    /// Each entry is either a rank-aggregated row
333    /// (`worker_id = None`) or a per-worker row (`worker_id = Some(w)`).
334    /// Empty when timing data was not collected (e.g. single-threaded runs
335    /// without the instrumentation wired). Written in iteration-major order:
336    /// rank-aggregated row first, then per-worker rows sorted by
337    /// `(rank, worker_id)`.
338    pub worker_timing_records: Vec<WorkerTimingRecord>,
339
340    /// Aggregate solve statistics for the training run.
341    ///
342    /// Default-constructed (all fields `None`) by producers that do not yet
343    /// record solve statistics; populated downstream and persisted into
344    /// `training/metadata.json` by the metadata writer.
345    pub training_solve_stats: MetadataTrainingSolveStats,
346}
347
348/// Aggregate type carrying simulation completion data for output writing.
349///
350/// Constructed by the simulation pipeline after it completes and optionally
351/// passed to [`write_results`]. When `None` is supplied, the simulation
352/// output directory is still created (ready for future use), but no
353/// simulation artifacts are written.
354#[derive(Debug, Clone)]
355pub struct SimulationOutput {
356    /// Total number of scenarios dispatched for simulation.
357    pub n_scenarios: u32,
358
359    /// Number of scenarios that completed without error.
360    pub completed: u32,
361
362    /// Number of scenarios that failed during simulation.
363    pub failed: u32,
364
365    /// Total elapsed wall-clock time for the simulation run (ms).
366    pub total_time_ms: u64,
367
368    /// Hive partition paths written by the simulation writer.
369    ///
370    /// Each element is a relative path string such as
371    /// `"simulation/costs/year=2030/month=01/part-00.parquet"`.
372    pub partitions_written: Vec<String>,
373
374    /// Aggregate cost statistics for the simulated scenarios.
375    ///
376    /// `None` until a producer supplies it. When several per-rank outputs are
377    /// combined via [`merge`](Self::merge), the first present value wins; the
378    /// producer is responsible for supplying the authoritative aggregate (which
379    /// the distributed pipeline computes on rank 0) first.
380    pub cost: Option<MetadataCost>,
381
382    /// Aggregate solve statistics for the simulation run.
383    ///
384    /// Default-constructed (all fields `None`) by producers that do not yet
385    /// record solve statistics; populated downstream and persisted into
386    /// `simulation/metadata.json` by the metadata writer.
387    pub solve_stats: MetadataSimulationSolveStats,
388}
389
390impl SimulationOutput {
391    /// Combine multiple per-rank simulation outputs into a single aggregate.
392    ///
393    /// Merge rules:
394    /// - `n_scenarios`: sum across all outputs.
395    /// - `completed`: sum across all outputs.
396    /// - `failed`: sum across all outputs.
397    /// - `total_time_ms`: max across all outputs (wall-clock = slowest rank).
398    /// - `partitions_written`: concatenation of all outputs' partitions, sorted
399    ///   for deterministic ordering regardless of input order.
400    /// - `cost`: first present value in slice order. The producer must supply
401    ///   the authoritative aggregate first (the distributed pipeline computes it
402    ///   on rank 0), so the merged cost is the rank-0 aggregate rather than a
403    ///   per-rank partial. `None` only when no input carried a cost.
404    /// - `solve_stats`: each count field is summed treating `None` as `0`, with
405    ///   the result `Some` when any input recorded it (and `None` only when no
406    ///   input did); `solve_seconds` is summed with the same convention;
407    ///   `parallelism` takes the maximum across inputs (`None`-safe). Sums and
408    ///   max are order-invariant, so the merge is declaration-order invariant.
409    ///
410    /// Returns a zeroed [`SimulationOutput`] (no cost, default solve stats) with
411    /// empty partitions when the input slice is empty.
412    #[must_use]
413    pub fn merge(outputs: &[Self]) -> Self {
414        if outputs.is_empty() {
415            return Self {
416                n_scenarios: 0,
417                completed: 0,
418                failed: 0,
419                total_time_ms: 0,
420                partitions_written: Vec::new(),
421                cost: None,
422                solve_stats: MetadataSimulationSolveStats::default(),
423            };
424        }
425
426        let n_scenarios = outputs.iter().map(|o| o.n_scenarios).sum();
427        let completed = outputs.iter().map(|o| o.completed).sum();
428        let failed = outputs.iter().map(|o| o.failed).sum();
429        let total_time_ms = outputs.iter().map(|o| o.total_time_ms).max().unwrap_or(0);
430
431        let mut partitions_written: Vec<String> = outputs
432            .iter()
433            .flat_map(|o| o.partitions_written.iter().cloned())
434            .collect();
435        partitions_written.sort();
436
437        let cost = outputs.iter().find_map(|o| o.cost.clone());
438
439        let solve_stats = merge_simulation_solve_stats(outputs);
440
441        Self {
442            n_scenarios,
443            completed,
444            failed,
445            total_time_ms,
446            partitions_written,
447            cost,
448            solve_stats,
449        }
450    }
451}
452
453/// Order-invariant sum of an optional `u64` field across simulation outputs.
454///
455/// Returns `Some(sum)` when at least one input carried the field (treating
456/// `None` as `0`), and `None` when no input recorded it. Addition is
457/// commutative, so the result is independent of slice order.
458fn sum_optional_u64(
459    outputs: &[SimulationOutput],
460    field: impl Fn(&MetadataSimulationSolveStats) -> Option<u64>,
461) -> Option<u64> {
462    let mut any = false;
463    let mut total: u64 = 0;
464    for output in outputs {
465        if let Some(value) = field(&output.solve_stats) {
466            any = true;
467            total = total.saturating_add(value);
468        }
469    }
470    any.then_some(total)
471}
472
473/// Combine per-rank simulation solve statistics into a single aggregate.
474///
475/// Count fields and `solve_seconds` are summed (treating `None` as `0`, result
476/// `Some` if any input was `Some`); `parallelism` takes the maximum. All
477/// operations are order-invariant.
478fn merge_simulation_solve_stats(outputs: &[SimulationOutput]) -> MetadataSimulationSolveStats {
479    let mut solve_seconds_any = false;
480    let mut solve_seconds_total: f64 = 0.0;
481    for output in outputs {
482        if let Some(value) = output.solve_stats.solve_seconds {
483            solve_seconds_any = true;
484            solve_seconds_total += value;
485        }
486    }
487
488    let parallelism = outputs
489        .iter()
490        .filter_map(|o| o.solve_stats.parallelism)
491        .max();
492
493    MetadataSimulationSolveStats {
494        total_lp_solves: sum_optional_u64(outputs, |s| s.total_lp_solves),
495        first_try: sum_optional_u64(outputs, |s| s.first_try),
496        retried: sum_optional_u64(outputs, |s| s.retried),
497        failed: sum_optional_u64(outputs, |s| s.failed),
498        solve_seconds: solve_seconds_any.then_some(solve_seconds_total),
499        parallelism,
500    }
501}
502
503#[cfg(test)]
504#[allow(
505    clippy::unwrap_used,
506    clippy::expect_used,
507    clippy::float_cmp,
508    clippy::cast_possible_truncation
509)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn training_output_construction_and_field_access() {
515        let records: Vec<IterationRecord> = (1..=5)
516            .map(|i| IterationRecord {
517                iteration: i,
518                lower_bound: 1.0,
519                upper_bound: 2.0,
520                upper_bound_std: 0.1,
521                gap_percent: Some(50.0),
522                cuts_added: 10,
523                cuts_removed: 2,
524                cuts_active: 8,
525                time_forward_ms: 100,
526                time_backward_ms: 200,
527                time_total_ms: 300,
528                forward_passes: 4,
529                lp_solves: 40,
530                time_forward_wall_ms: 100,
531                time_backward_wall_ms: 200,
532                time_cut_selection_ms: 0,
533                time_mpi_allreduce_ms: 0,
534                time_cut_sync_ms: 0,
535                time_lower_bound_ms: 0,
536                time_state_exchange_ms: 0,
537                time_cut_batch_build_ms: 0,
538                time_bwd_setup_ms: 0,
539                time_bwd_load_imbalance_ms: 0,
540                time_bwd_scheduling_overhead_ms: 0,
541                time_fwd_setup_ms: 0,
542                time_fwd_load_imbalance_ms: 0,
543                time_fwd_scheduling_overhead_ms: 0,
544                time_overhead_ms: 0,
545                solve_time_ms: 0.0,
546                mean_rows_in_lp: 0.0,
547            })
548            .collect();
549        let output = TrainingOutput {
550            convergence_records: records,
551            final_lower_bound: 50.0,
552            final_upper_bound: Some(52.0),
553            final_gap_percent: Some(3.85),
554            final_upper_bound_std: Some(0.5),
555            final_upper_bound_kind: "statistical".to_string(),
556            iterations_completed: 5,
557            converged: true,
558            termination_reason: "relative gap < 1%".to_string(),
559            total_time_ms: 12_000,
560            cut_stats: RowPoolStatistics {
561                total_generated: 300,
562                total_active: 120,
563                peak_active: 150,
564                cuts_active: 120,
565                rows_in_lp_total: 0,
566                rows_in_lp_solve_count: 0,
567                rows_in_lp_max: 0,
568                total_loaded: 0,
569            },
570            cut_selection_records: vec![],
571            worker_timing_records: vec![],
572            training_solve_stats: MetadataTrainingSolveStats::default(),
573        };
574
575        assert_eq!(output.convergence_records.len(), 5);
576        assert_eq!(output.final_lower_bound, 50.0);
577        assert_eq!(output.final_upper_bound, Some(52.0));
578        assert_eq!(output.final_gap_percent, Some(3.85));
579        assert_eq!(output.final_upper_bound_std, Some(0.5));
580        assert_eq!(output.iterations_completed, 5);
581        assert!(output.converged);
582        assert_eq!(output.termination_reason, "relative gap < 1%");
583        assert_eq!(output.total_time_ms, 12_000);
584        assert_eq!(output.cut_stats.total_generated, 300);
585        assert_eq!(output.cut_stats.total_active, 120);
586        assert_eq!(output.cut_stats.peak_active, 150);
587    }
588
589    #[test]
590    fn iteration_record_construction_and_field_access() {
591        let record = IterationRecord {
592            iteration: 7,
593            lower_bound: 10.5,
594            upper_bound: 11.0,
595            upper_bound_std: 0.25,
596            gap_percent: Some(4.55),
597            cuts_added: 15,
598            cuts_removed: 3,
599            cuts_active: 42,
600            time_forward_ms: 150,
601            time_backward_ms: 250,
602            time_total_ms: 400,
603            forward_passes: 8,
604            lp_solves: 80,
605            time_forward_wall_ms: 150,
606            time_backward_wall_ms: 250,
607            time_cut_selection_ms: 5,
608            time_mpi_allreduce_ms: 3,
609            time_cut_sync_ms: 2,
610            time_lower_bound_ms: 4,
611            time_state_exchange_ms: 0,
612            time_cut_batch_build_ms: 0,
613            time_bwd_setup_ms: 0,
614            time_bwd_load_imbalance_ms: 0,
615            time_bwd_scheduling_overhead_ms: 0,
616            time_fwd_setup_ms: 0,
617            time_fwd_load_imbalance_ms: 0,
618            time_fwd_scheduling_overhead_ms: 0,
619            time_overhead_ms: 400u64.saturating_sub(150 + 250 + 5 + 3 + 4),
620            solve_time_ms: 0.0,
621            mean_rows_in_lp: 0.0,
622        };
623
624        assert_eq!(record.iteration, 7);
625        assert_eq!(record.lower_bound, 10.5);
626        assert_eq!(record.upper_bound, 11.0);
627        assert_eq!(record.upper_bound_std, 0.25);
628        assert_eq!(record.gap_percent, Some(4.55));
629        assert_eq!(record.cuts_added, 15);
630        assert_eq!(record.cuts_removed, 3);
631        assert_eq!(record.cuts_active, 42);
632        assert_eq!(record.time_forward_ms, 150);
633        assert_eq!(record.time_backward_ms, 250);
634        assert_eq!(record.time_total_ms, 400);
635        assert_eq!(record.forward_passes, 8);
636        assert_eq!(record.lp_solves, 80);
637        assert_eq!(record.time_forward_wall_ms, 150);
638        assert_eq!(record.time_backward_wall_ms, 250);
639        assert_eq!(record.time_cut_selection_ms, 5);
640        assert_eq!(record.time_mpi_allreduce_ms, 3);
641        assert_eq!(record.time_cut_sync_ms, 2);
642        assert_eq!(record.time_lower_bound_ms, 4);
643    }
644
645    #[test]
646    fn simulation_output_construction_and_field_access() {
647        let output = SimulationOutput {
648            n_scenarios: 100,
649            completed: 100,
650            failed: 0,
651            total_time_ms: 3_200,
652            partitions_written: vec![
653                "simulation/costs/year=2030/part-00.parquet".to_string(),
654                "simulation/costs/year=2031/part-00.parquet".to_string(),
655            ],
656            cost: None,
657            solve_stats: MetadataSimulationSolveStats::default(),
658        };
659
660        assert_eq!(output.n_scenarios, 100);
661        assert_eq!(output.completed, 100);
662        assert_eq!(output.failed, 0);
663        assert_eq!(output.total_time_ms, 3_200);
664        assert_eq!(output.partitions_written.len(), 2);
665    }
666
667    #[test]
668    fn row_pool_statistics_construction() {
669        let stats = RowPoolStatistics {
670            total_generated: 500,
671            total_active: 200,
672            peak_active: 250,
673            cuts_active: 200,
674            rows_in_lp_total: 0,
675            rows_in_lp_solve_count: 0,
676            rows_in_lp_max: 0,
677            total_loaded: 0,
678        };
679
680        assert_eq!(stats.total_generated, 500);
681        assert_eq!(stats.total_active, 200);
682        assert_eq!(stats.peak_active, 250);
683        assert_eq!(stats.cuts_active, 200);
684    }
685
686    #[test]
687    fn row_pool_statistics_serializes_with_new_fields() {
688        let stats = RowPoolStatistics {
689            total_generated: 10,
690            total_active: 7,
691            peak_active: 9,
692            cuts_active: 7,
693            rows_in_lp_total: 30,
694            rows_in_lp_solve_count: 6,
695            rows_in_lp_max: 8,
696            total_loaded: 3,
697        };
698        let json = serde_json::to_string(&stats).expect("serialization must succeed");
699        assert!(
700            !json.contains("\"cuts_in_lp\""),
701            "JSON must not contain cuts_in_lp key"
702        );
703        assert!(
704            json.contains("\"cuts_active\""),
705            "JSON must contain cuts_active key"
706        );
707        for key in [
708            "\"rows_in_lp_total\"",
709            "\"rows_in_lp_solve_count\"",
710            "\"rows_in_lp_max\"",
711            "\"total_loaded\"",
712        ] {
713            assert!(json.contains(key), "JSON must contain {key}");
714        }
715    }
716
717    #[test]
718    fn test_merge_empty_slice() {
719        let merged = SimulationOutput::merge(&[]);
720        assert_eq!(merged.n_scenarios, 0);
721        assert_eq!(merged.completed, 0);
722        assert_eq!(merged.failed, 0);
723        assert_eq!(merged.total_time_ms, 0);
724        assert!(merged.partitions_written.is_empty());
725    }
726
727    #[test]
728    fn test_merge_single_output() {
729        let output = SimulationOutput {
730            n_scenarios: 5,
731            completed: 4,
732            failed: 1,
733            total_time_ms: 1000,
734            partitions_written: vec!["simulation/costs/scenario_id=0000/data.parquet".to_string()],
735            cost: None,
736            solve_stats: MetadataSimulationSolveStats::default(),
737        };
738        let merged = SimulationOutput::merge(std::slice::from_ref(&output));
739        assert_eq!(merged.n_scenarios, 5);
740        assert_eq!(merged.completed, 4);
741        assert_eq!(merged.failed, 1);
742        assert_eq!(merged.total_time_ms, 1000);
743        assert_eq!(merged.partitions_written, output.partitions_written);
744    }
745
746    #[test]
747    fn test_merge_two_outputs() {
748        let a = SimulationOutput {
749            n_scenarios: 3,
750            completed: 3,
751            failed: 0,
752            total_time_ms: 500,
753            partitions_written: vec![
754                "simulation/costs/scenario_id=0000/data.parquet".to_string(),
755                "simulation/costs/scenario_id=0001/data.parquet".to_string(),
756            ],
757            cost: None,
758            solve_stats: MetadataSimulationSolveStats::default(),
759        };
760        let b = SimulationOutput {
761            n_scenarios: 2,
762            completed: 1,
763            failed: 1,
764            total_time_ms: 800,
765            partitions_written: vec!["simulation/costs/scenario_id=0002/data.parquet".to_string()],
766            cost: None,
767            solve_stats: MetadataSimulationSolveStats::default(),
768        };
769        let merged = SimulationOutput::merge(&[a, b]);
770        assert_eq!(merged.n_scenarios, 5);
771        assert_eq!(merged.completed, 4);
772        assert_eq!(merged.failed, 1);
773        // total_time_ms uses max, not sum
774        assert_eq!(merged.total_time_ms, 800);
775        assert_eq!(merged.partitions_written.len(), 3);
776    }
777
778    #[test]
779    fn test_merge_partitions_sorted() {
780        let a = SimulationOutput {
781            n_scenarios: 1,
782            completed: 1,
783            failed: 0,
784            total_time_ms: 100,
785            partitions_written: vec![
786                "simulation/hydros/scenario_id=0002/data.parquet".to_string(),
787                "simulation/costs/scenario_id=0002/data.parquet".to_string(),
788            ],
789            cost: None,
790            solve_stats: MetadataSimulationSolveStats::default(),
791        };
792        let b = SimulationOutput {
793            n_scenarios: 1,
794            completed: 1,
795            failed: 0,
796            total_time_ms: 200,
797            partitions_written: vec![
798                "simulation/costs/scenario_id=0001/data.parquet".to_string(),
799                "simulation/hydros/scenario_id=0001/data.parquet".to_string(),
800            ],
801            cost: None,
802            solve_stats: MetadataSimulationSolveStats::default(),
803        };
804        let merged = SimulationOutput::merge(&[a, b]);
805        let expected = vec![
806            "simulation/costs/scenario_id=0001/data.parquet".to_string(),
807            "simulation/costs/scenario_id=0002/data.parquet".to_string(),
808            "simulation/hydros/scenario_id=0001/data.parquet".to_string(),
809            "simulation/hydros/scenario_id=0002/data.parquet".to_string(),
810        ];
811        assert_eq!(merged.partitions_written, expected);
812    }
813
814    #[test]
815    fn simulation_output_merge_combines_solve_stats_order_invariant() {
816        let a = SimulationOutput {
817            n_scenarios: 2,
818            completed: 2,
819            failed: 0,
820            total_time_ms: 500,
821            partitions_written: vec![],
822            cost: Some(MetadataCost {
823                mean_cost: 100.0,
824                std_cost: 10.0,
825            }),
826            solve_stats: MetadataSimulationSolveStats {
827                total_lp_solves: Some(40),
828                first_try: Some(35),
829                retried: Some(5),
830                failed: Some(0),
831                solve_seconds: Some(1.5),
832                parallelism: Some(4),
833            },
834        };
835        let b = SimulationOutput {
836            n_scenarios: 3,
837            completed: 3,
838            failed: 0,
839            total_time_ms: 800,
840            partitions_written: vec![],
841            cost: Some(MetadataCost {
842                mean_cost: 200.0,
843                std_cost: 20.0,
844            }),
845            solve_stats: MetadataSimulationSolveStats {
846                total_lp_solves: Some(60),
847                first_try: Some(50),
848                retried: Some(8),
849                failed: Some(2),
850                solve_seconds: Some(2.5),
851                parallelism: Some(8),
852            },
853        };
854
855        let merged_ab = SimulationOutput::merge(&[a.clone(), b.clone()]);
856        let merged_ba = SimulationOutput::merge(&[b, a]);
857
858        assert_eq!(
859            merged_ab.solve_stats.total_lp_solves,
860            merged_ba.solve_stats.total_lp_solves
861        );
862        assert_eq!(merged_ab.solve_stats.total_lp_solves, Some(100));
863        assert_eq!(merged_ab.solve_stats.first_try, Some(85));
864        assert_eq!(
865            merged_ab.solve_stats.first_try,
866            merged_ba.solve_stats.first_try
867        );
868        assert_eq!(merged_ab.solve_stats.retried, Some(13));
869        assert_eq!(merged_ab.solve_stats.retried, merged_ba.solve_stats.retried);
870        assert_eq!(merged_ab.solve_stats.failed, Some(2));
871        assert_eq!(merged_ab.solve_stats.failed, merged_ba.solve_stats.failed);
872
873        assert_eq!(merged_ab.solve_stats.solve_seconds, Some(4.0));
874        assert_eq!(
875            merged_ab.solve_stats.solve_seconds,
876            merged_ba.solve_stats.solve_seconds
877        );
878
879        assert_eq!(merged_ab.solve_stats.parallelism, Some(8));
880        assert_eq!(
881            merged_ab.solve_stats.parallelism,
882            merged_ba.solve_stats.parallelism
883        );
884
885        assert_eq!(
886            merged_ab.cost.as_ref().map(|c| c.mean_cost),
887            Some(100.0),
888            "first-present cost wins in [a, b] order"
889        );
890        assert_eq!(
891            merged_ba.cost.as_ref().map(|c| c.mean_cost),
892            Some(200.0),
893            "first-present cost wins in [b, a] order"
894        );
895    }
896
897    #[test]
898    fn simulation_output_merge_solve_stats_none_when_no_input_records() {
899        let a = SimulationOutput {
900            n_scenarios: 1,
901            completed: 1,
902            failed: 0,
903            total_time_ms: 100,
904            partitions_written: vec![],
905            cost: None,
906            solve_stats: MetadataSimulationSolveStats::default(),
907        };
908        let merged = SimulationOutput::merge(std::slice::from_ref(&a));
909        assert_eq!(merged.solve_stats.total_lp_solves, None);
910        assert_eq!(merged.solve_stats.solve_seconds, None);
911        assert_eq!(merged.solve_stats.parallelism, None);
912        assert!(merged.cost.is_none());
913    }
914}