Skip to main content

henad_explore/output/
runs_csv.rs

1//! Writer of `runs.csv`, one row per run in plan order.
2//!
3//! A row holds the ids, seed and key of a run, the value of every parameter and the tick of every action, the status
4//! and timing of the run, and one value per reducer. The note is the last column. It holds the actions that the model
5//! rejected, then a fault message, the timeout or the first value that was not finite.
6
7use std::io::{self, Write};
8
9use henad_core::explore::outcome::RunOutcome;
10use henad_core::explore::plan::Config;
11use henad_core::explore::spec::ActionSpec;
12use henad_core::explore::value::format_value;
13use henad_core::export::csv::{escape_field, fmt_f64};
14use henad_core::params::{ParamDescriptor, ParamKind};
15
16/// Headers of the columns before the parameters.
17pub const ID_COLUMNS: [&str; 6] = ["run_id", "config_id", "block", "rep", "seed", "run_key"];
18
19/// Headers of the columns between the parameters and the reducers.
20pub const OUTCOME_COLUMNS: [&str; 7] = [
21    "status",
22    "stop_reason",
23    "ticks",
24    "population",
25    "build_ms",
26    "wall_ms",
27    "steps_per_s",
28];
29
30/// Headers of the columns that depend on the machine and its load, and on nothing in the plan.
31pub const TIMING_COLUMNS: [&str; 3] = ["build_ms", "wall_ms", "steps_per_s"];
32
33/// Header of the last column.
34pub const NOTE_COLUMN: &str = "note";
35
36/// Returns the names of the columns of `runs.csv` for the parameters `params`, the actions `actions` and the reducer
37/// columns named `reducers`, before CSV escaping.
38pub(crate) fn column_names(params: &[ParamDescriptor], actions: &[ActionSpec], reducers: &[String]) -> Vec<String> {
39    let mut names: Vec<String> = ID_COLUMNS.iter().map(|&column| column.to_owned()).collect();
40    names.extend(params.iter().map(|param| param.id.to_owned()));
41    names.extend(actions.iter().map(ActionSpec::column_name));
42    names.extend(OUTCOME_COLUMNS.iter().map(|&column| column.to_owned()));
43    names.extend(reducers.iter().cloned());
44    names.push(NOTE_COLUMN.to_owned());
45    names
46}
47
48/// Returns the header line of `runs.csv` for the column names `names`, escaped and with its line ending.
49pub(crate) fn header_line(names: &[String]) -> String {
50    let mut line = names
51        .iter()
52        .map(|name| escape_field(name))
53        .collect::<Vec<_>>()
54        .join(",");
55    line.push('\n');
56    line
57}
58
59/// Writer of `runs.csv`.
60#[derive(Debug)]
61pub struct RunsWriter<W: Write> {
62    dest: W,
63    /// Kind of every parameter, in descriptor order.
64    kinds: Vec<ParamKind>,
65}
66
67impl<W: Write> RunsWriter<W> {
68    /// Writes the header for the parameters `params`, the actions `actions` and the reducer columns named
69    /// `reducers`.
70    ///
71    /// # Errors
72    ///
73    /// Returns the error of the write.
74    pub fn new(
75        mut dest: W,
76        params: &[ParamDescriptor],
77        actions: &[ActionSpec],
78        reducers: &[String],
79    ) -> io::Result<Self> {
80        dest.write_all(header_line(&column_names(params, actions, reducers)).as_bytes())?;
81        Ok(Self::appending(dest, params))
82    }
83
84    /// Returns a writer that adds rows to `dest`, a `runs.csv` whose header is already written, for the parameters
85    /// `params`.
86    pub fn appending(dest: W, params: &[ParamDescriptor]) -> Self {
87        Self {
88            dest,
89            kinds: params.iter().map(|param| param.kind.clone()).collect(),
90        }
91    }
92
93    /// Writes the row of `outcome`, a run of `config`.
94    ///
95    /// # Errors
96    ///
97    /// Returns the error of the write.
98    pub fn write_run(&mut self, outcome: &RunOutcome, config: &Config) -> io::Result<()> {
99        let run = &outcome.run;
100        let mut row = format!(
101            "{},{},{},{},{},{:016x}",
102            run.run_id, run.config_id, config.block, run.rep, run.seed, outcome.run_key
103        );
104        for (kind, value) in self.kinds.iter().zip(&config.params) {
105            row.push(',');
106            row.push_str(&escape_field(&format_value(kind, value)));
107        }
108        for tick in &config.action_ticks {
109            row.push_str(&format!(",{tick}"));
110        }
111        let timings = [outcome.build_ms, outcome.wall_ms, outcome.steps_per_s()].map(format_timing);
112        row.push_str(&format!(
113            ",{},{},{},{},{},{},{}",
114            outcome.status.as_str(),
115            outcome.stop_reason.as_str(),
116            outcome.ticks,
117            outcome.population,
118            timings[0],
119            timings[1],
120            timings[2],
121        ));
122        for value in &outcome.reducers {
123            row.push(',');
124            row.push_str(&value.map_or_else(String::new, fmt_f64));
125        }
126        row.push(',');
127        row.push_str(&escape_field(outcome.note.as_deref().unwrap_or_default()));
128        writeln!(self.dest, "{row}")
129    }
130
131    /// Flushes the rows written so far.
132    ///
133    /// # Errors
134    ///
135    /// Returns the error of the flush.
136    pub fn flush(&mut self) -> io::Result<()> {
137        self.dest.flush()
138    }
139
140    /// Flushes the writer and returns it.
141    ///
142    /// # Errors
143    ///
144    /// Returns the error of the flush.
145    pub fn into_inner(mut self) -> io::Result<W> {
146        self.dest.flush()?;
147        Ok(self.dest)
148    }
149}
150
151/// Returns a time or a rate rounded to three decimal places, or an empty cell for a value that is not finite.
152fn format_timing(value: f64) -> String {
153    fmt_f64((value * 1000.0).round() / 1000.0)
154}
155
156#[cfg(test)]
157mod tests {
158    use henad_core::explore::measure::SeriesBuffer;
159    use henad_core::explore::outcome::{PlannedRun, RunOutcome, RunStatus, StopReason};
160    use henad_core::explore::plan::Config;
161    use henad_core::explore::spec::ActionSpec;
162    use henad_core::helpers::{choice_param, f32_param};
163    use henad_core::params::ParamValue;
164
165    use super::{RunsWriter, format_timing};
166
167    const NETWORKS: &[&str] = &["Random", "Geometric"];
168
169    #[test]
170    fn a_row_holds_every_column_in_header_order() {
171        let params = [
172            f32_param("spread, chance", "Spread chance", 0.2, 0.0, 1.0, None),
173            choice_param("network", "Network", NETWORKS, 0),
174        ];
175        let reducers = ["Infected:max".to_owned(), "Speed.[0, 1):min".to_owned()];
176        let actions = [ActionSpec {
177            name: "second, wave".to_owned(),
178            ..ActionSpec::new("seed_outbreak", 40)
179        }];
180        let mut writer = RunsWriter::new(Vec::new(), &params, &actions, &reducers).expect("a vector takes every write");
181        let outcome = RunOutcome {
182            run: PlannedRun {
183                run_id: 7,
184                config_id: 2,
185                rep: 1,
186                seed: 99,
187            },
188            run_key: 0xab,
189            status: RunStatus::Panicked,
190            stop_reason: StopReason::Fault,
191            ticks: 40,
192            population: 256,
193            build_ms: 1.23456,
194            wall_ms: 8.0,
195            reducers: vec![Some(12.5), None],
196            series: SeriesBuffer::new(2),
197            note: Some("while stepping, \"x\" panicked".to_owned()),
198        };
199        let config = Config {
200            block: 1,
201            params: vec![ParamValue::F32(0.1), ParamValue::Choice(1)],
202            action_ticks: vec![25],
203        };
204        writer.write_run(&outcome, &config).expect("a vector takes every write");
205        let text = String::from_utf8(writer.into_inner().expect("a vector flushes")).expect("the rows are UTF-8");
206        let lines: Vec<&str> = text.lines().collect();
207        assert_eq!(
208            lines[0],
209            "run_id,config_id,block,rep,seed,run_key,\"spread, chance\",network,\"action.second, wave\",status,\
210             stop_reason,ticks,population,build_ms,wall_ms,steps_per_s,Infected:max,\"Speed.[0, 1):min\",note"
211        );
212        assert_eq!(
213            lines[1],
214            "7,2,1,1,99,00000000000000ab,0.1,Geometric,25,panicked,fault,40,256,1.235,8,5000,12.5,,\
215             \"while stepping, \"\"x\"\" panicked\""
216        );
217    }
218
219    #[test]
220    fn a_timing_keeps_three_decimal_places() {
221        assert_eq!(format_timing(0.1 + 0.2), "0.3");
222        assert_eq!(format_timing(2.0), "2");
223        assert_eq!(format_timing(f64::INFINITY), "");
224        assert_eq!(format_timing(f64::NAN), "");
225    }
226}