1use std::collections::BTreeMap;
8use std::collections::btree_map::Entry;
9use std::fmt;
10use std::io::{self, BufRead, Write};
11use std::ops::Range;
12
13use henad_core::explore::outcome::RunStatus;
14use henad_core::explore::summary::{ReplicateSummary, SummaryAccumulator};
15use henad_core::export::csv::{CsvError, escape_field, fmt_f64, parse_records};
16
17use crate::output::read::{RecordScan, shifted};
18use crate::output::runs_csv::{NOTE_COLUMN, OUTCOME_COLUMNS};
19
20pub const STATISTICS: [&str; 5] = ["mean", "sd", "n", "ci95_low", "ci95_high"];
22
23#[derive(Debug)]
25pub enum SummaryError {
26 Csv(CsvError),
28 MissingColumn {
30 column: &'static str,
32 },
33 FieldCount {
35 record_number: usize,
37 found: usize,
39 expected: usize,
41 },
42 BadField {
44 record_number: usize,
46 column: String,
48 text: String,
50 },
51 Read(io::Error),
53 Io(io::Error),
55}
56
57impl fmt::Display for SummaryError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Self::Csv(_) => f.write_str("runs.csv is not valid CSV"),
61 Self::Read(_) => f.write_str("cannot read runs.csv"),
62 Self::MissingColumn { column } => write!(f, "runs.csv has no '{column}' column"),
63 Self::FieldCount {
64 record_number,
65 found,
66 expected,
67 } => write!(
68 f,
69 "record {record_number} of runs.csv has {found} fields, expected {expected}"
70 ),
71 Self::BadField {
72 record_number,
73 column,
74 text,
75 } => {
76 write!(
77 f,
78 "record {record_number} of runs.csv has invalid value '{text}' in column '{column}'"
79 )
80 }
81 Self::Io(_) => f.write_str("cannot write the summary"),
82 }
83 }
84}
85
86impl std::error::Error for SummaryError {
87 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
88 match self {
89 Self::Csv(error) => Some(error),
90 Self::Read(error) | Self::Io(error) => Some(error),
91 Self::MissingColumn { .. } | Self::FieldCount { .. } | Self::BadField { .. } => None,
92 }
93 }
94}
95
96impl From<io::Error> for SummaryError {
97 fn from(error: io::Error) -> Self {
98 Self::Io(error)
99 }
100}
101
102struct RunsLayout {
106 config_id: usize,
107 block: usize,
108 values: Range<usize>,
110 status: usize,
111 ticks: usize,
112 reducers: Range<usize>,
114}
115
116impl RunsLayout {
117 fn read(header: &[String]) -> Result<Self, SummaryError> {
120 let find_first = |column: &'static str| {
121 header
122 .iter()
123 .position(|name| name == column)
124 .ok_or(SummaryError::MissingColumn { column })
125 };
126 let find_last_from = |start: usize, column: &'static str| {
127 header
128 .get(start..)
129 .and_then(|rest| rest.iter().rposition(|name| name == column))
130 .map(|position| start + position)
131 .ok_or(SummaryError::MissingColumn { column })
132 };
133 let last_outcome = OUTCOME_COLUMNS[OUTCOME_COLUMNS.len() - 1];
134 let config_id = find_first("config_id")?;
135 let block = find_first("block")?;
136 let values_start = find_first("run_key")? + 1;
137 let status = find_last_from(values_start, "status")?;
138 let reducers_start = find_last_from(status, last_outcome)? + 1;
139 Ok(Self {
140 config_id,
141 block,
142 values: values_start..status,
143 status,
144 ticks: find_last_from(status, "ticks")?,
145 reducers: reducers_start..find_last_from(reducers_start, NOTE_COLUMN)?,
146 })
147 }
148}
149
150struct RecordReader<R> {
152 source: R,
153 text: Vec<u8>,
155 line_feeds: usize,
157}
158
159impl<R: BufRead> RecordReader<R> {
160 fn new(source: R) -> Self {
161 Self {
162 source,
163 text: Vec::new(),
164 line_feeds: 0,
165 }
166 }
167
168 fn next_record(&mut self) -> Result<Option<Vec<String>>, SummaryError> {
171 self.text.clear();
172 let lines_before = self.line_feeds;
173 let mut scan = RecordScan::FieldStart;
174 let mut record_ended = false;
175 while !record_ended {
176 let start = self.text.len();
177 if self
179 .source
180 .read_until(b'\n', &mut self.text)
181 .map_err(SummaryError::Read)?
182 == 0
183 {
184 break;
185 }
186 for &byte in &self.text[start..] {
187 (scan, record_ended) = scan.advance(byte);
188 }
189 if self.text.ends_with(b"\n") {
190 self.line_feeds += 1;
191 }
192 }
193 if self.text.is_empty() {
194 return Ok(None);
195 }
196 let text = std::str::from_utf8(&self.text)
197 .map_err(|error| SummaryError::Read(io::Error::new(io::ErrorKind::InvalidData, error)))?;
198 let record = parse_records(text)
199 .map_err(|error| SummaryError::Csv(shifted(&error, lines_before)))?
200 .into_iter()
201 .next();
202 Ok(record)
203 }
204}
205
206pub(crate) fn write_summary<R: BufRead, W: Write>(runs: R, mut dest: W) -> Result<W, SummaryError> {
215 let mut records = RecordReader::new(runs);
216 let Some(header) = records.next_record()? else {
217 return Err(SummaryError::MissingColumn { column: "run_id" });
218 };
219 let layout = RunsLayout::read(&header)?;
220 let reducer_names = &header[layout.reducers.clone()];
221
222 let mut accumulator = SummaryAccumulator::new(reducer_names.len());
223 let mut configs: BTreeMap<u64, String> = BTreeMap::new();
225 let mut reducers = Vec::with_capacity(reducer_names.len());
226 let mut record_number = 1;
227 while let Some(row) = records.next_record()? {
228 record_number += 1;
229 if row.len() != header.len() {
230 return Err(SummaryError::FieldCount {
231 record_number,
232 found: row.len(),
233 expected: header.len(),
234 });
235 }
236 let bad_field = |column: usize| SummaryError::BadField {
237 record_number,
238 column: header[column].clone(),
239 text: row[column].clone(),
240 };
241 let config_id: u64 = row[layout.config_id]
242 .parse()
243 .ok()
244 .ok_or_else(|| bad_field(layout.config_id))?;
245 let status: RunStatus = row[layout.status]
246 .parse()
247 .ok()
248 .ok_or_else(|| bad_field(layout.status))?;
249 let ticks: u64 = row[layout.ticks].parse().ok().ok_or_else(|| bad_field(layout.ticks))?;
250 reducers.clear();
251 for column in layout.reducers.clone() {
252 let value = match row[column].as_str() {
253 "" => None,
254 text => Some(text.parse::<f64>().ok().ok_or_else(|| bad_field(column))?),
255 };
256 reducers.push(value);
257 }
258 accumulator.push(config_id, status, ticks, &reducers);
259 if let Entry::Vacant(entry) = configs.entry(config_id) {
260 let mut cells = escape_field(&row[layout.block]);
261 for value in &row[layout.values.clone()] {
262 cells.push(',');
263 cells.push_str(&escape_field(value));
264 }
265 entry.insert(cells);
266 }
267 }
268
269 let mut columns: Vec<String> = ["config_id", "block"].map(str::to_owned).to_vec();
270 columns.extend(header[layout.values.clone()].iter().map(|name| escape_field(name)));
271 columns.extend(["runs", "ok", "failed", "ticks:mean"].map(str::to_owned));
272 for name in reducer_names {
273 columns.extend(STATISTICS.map(|statistic| escape_field(&format!("{name}:{statistic}"))));
274 }
275 writeln!(dest, "{}", columns.join(","))?;
276 for summary in accumulator.rows() {
277 let Some(config_cells) = configs.remove(&summary.config_id) else {
278 continue;
279 };
280 let mut cells = vec![summary.config_id.to_string(), config_cells];
281 cells.extend([summary.runs, summary.ok, summary.failed].map(|count| count.to_string()));
282 cells.push(optional_cell(summary.ticks.mean));
283 for reducer in &summary.reducers {
284 cells.extend(statistic_cells(reducer));
285 }
286 writeln!(dest, "{}", cells.join(","))?;
287 }
288 dest.flush()?;
289 Ok(dest)
290}
291
292fn statistic_cells(summary: &ReplicateSummary) -> [String; 5] {
294 [
295 optional_cell(summary.mean),
296 optional_cell(summary.standard_deviation),
297 summary.n.to_string(),
298 optional_cell(summary.ci95.map(|(low, _)| low)),
299 optional_cell(summary.ci95.map(|(_, high)| high)),
300 ]
301}
302
303fn optional_cell(value: Option<f64>) -> String {
305 value.map_or_else(String::new, fmt_f64)
306}
307
308#[cfg(test)]
309mod tests {
310 use std::io::BufReader;
311
312 use henad_core::export::csv::CsvError;
313
314 use super::{SummaryError, write_summary};
315
316 const RUNS: &str = "\
317run_id,config_id,block,rep,seed,run_key,rate,\"a, b\",status,stop_reason,ticks,population,build_ms,wall_ms,steps_per_s,Infected:max,\"Speed.[0, 1):min\",note
3180,0,0,0,11,00000000000000aa,0.1,x,ok,steps,10,64,1,2,5000,1,,
3191,0,0,1,12,00000000000000ab,0.1,x,ok,steps,10,64,1,2,5000,2,4,
3202,0,0,2,13,00000000000000ac,0.1,x,non_finite,steps,10,64,1,2,5000,3,,Infected is not finite at tick 5
3213,0,0,3,14,00000000000000ad,0.1,x,ok,steps,10,64,1,2,5000,4,,
3224,1,1,0,11,00000000000000ba,0.2,\"y, z\",panicked,fault,3,64,1,2,1500,99,,\"while stepping, it panicked\"
3235,1,1,1,12,00000000000000bb,0.2,\"y, z\",ok,steps,10,64,1,2,5000,7,1,
324";
325
326 fn summary(runs: &str) -> Result<String, SummaryError> {
327 write_summary(runs.as_bytes(), Vec::new()).map(|bytes| String::from_utf8(bytes).expect("the rows are UTF-8"))
328 }
329
330 #[test]
331 fn a_record_read_across_many_reads_is_summarized_as_one() {
332 let runs = RUNS.replace(
333 "x,ok,steps,10,64,1,2,5000,1,,",
334 "\"x\",ok,steps,10,64,1,2,5000,1,,\"two\nlines\"",
335 );
336 let whole = summary(&runs).expect("a valid runs.csv");
337 let byte_by_byte =
338 write_summary(BufReader::with_capacity(1, runs.as_bytes()), Vec::new()).expect("a valid runs.csv");
339 assert_eq!(String::from_utf8(byte_by_byte).expect("the rows are UTF-8"), whole);
340 assert_eq!(
341 whole,
342 summary(RUNS).expect("a valid runs.csv"),
343 "quotes change no value"
344 );
345
346 let stray = runs.replace("5,1,1,1,12,", "5,1,1,1,1\"2,");
347 assert!(
348 matches!(
349 summary(&stray),
350 Err(SummaryError::Csv(CsvError::MisplacedQuote { line: 8 }))
351 ),
352 "the line counts from the start of the file, the quoted line feed included"
353 );
354 }
355
356 #[test]
357 fn a_header_out_of_order_is_refused() {
358 let note_first = RUNS
359 .replacen(",note\n", ",Late\n", 1)
360 .replacen("run_key,", "run_key,note,", 1);
361 assert!(matches!(
362 summary(note_first.lines().next().expect("a header")),
363 Err(SummaryError::MissingColumn { column: "note" })
364 ));
365 let status_first = "status,run_id,config_id,block,rep,seed,run_key,stop_reason,ticks,population,build_ms,wall_ms,steps_per_s,note\n";
366 assert!(matches!(
367 summary(status_first),
368 Err(SummaryError::MissingColumn { column: "status" })
369 ));
370 }
371
372 #[test]
373 fn the_summary_matches_hand_computed_statistics() {
374 let text = summary(RUNS).expect("a valid runs.csv");
375 let lines: Vec<&str> = text.lines().collect();
376 assert_eq!(
377 lines[0],
378 "config_id,block,rate,\"a, b\",runs,ok,failed,ticks:mean,\
379 Infected:max:mean,Infected:max:sd,Infected:max:n,Infected:max:ci95_low,Infected:max:ci95_high,\
380 \"Speed.[0, 1):min:mean\",\"Speed.[0, 1):min:sd\",\"Speed.[0, 1):min:n\",\
381 \"Speed.[0, 1):min:ci95_low\",\"Speed.[0, 1):min:ci95_high\""
382 );
383 let first: Vec<&str> = lines[1].split(',').collect();
384 assert_eq!(first[..8], ["0", "0", "0.1", "x", "4", "3", "0", "10"]);
385 assert_eq!(first[8], "2.5", "the mean of 1, 2, 3 and 4");
386 let sd: f64 = first[9].parse().expect("a spread");
387 assert!((sd - 1.290_994).abs() < 1e-6, "sd {sd}");
388 assert_eq!(first[10], "4");
389 let low: f64 = first[11].parse().expect("an interval");
390 assert!((2.5 - low - 2.054_260).abs() < 1e-6, "half-width {}", 2.5 - low);
391 assert_eq!(first[13..], ["4", "", "1", "", ""], "one finite value has no spread");
392
393 assert_eq!(
394 lines[2], "1,1,0.2,\"y, z\",2,1,1,10,7,,1,,,1,,1,,",
395 "the failed run is counted and left out"
396 );
397 assert_eq!(lines.len(), 3);
398 }
399
400 #[test]
401 fn a_parameter_named_like_a_later_column_is_read_as_a_parameter() {
402 let runs = "\
403run_id,config_id,block,rep,seed,run_key,ticks,status,note,status,stop_reason,ticks,population,build_ms,wall_ms,steps_per_s,Infected:max,note
4040,0,0,0,11,00000000000000aa,5,x,y,ok,steps,10,64,1,2,5000,1,
4051,0,0,1,12,00000000000000ab,5,x,y,ok,steps,10,64,1,2,5000,3,
406";
407 let text = summary(runs).expect("a valid runs.csv");
408 let lines: Vec<&str> = text.lines().collect();
409 assert!(
410 lines[0].starts_with("config_id,block,ticks,status,note,runs,ok,failed,ticks:mean,Infected:max:mean,"),
411 "{}",
412 lines[0]
413 );
414 let first: Vec<&str> = lines[1].split(',').collect();
415 assert_eq!(first[..10], ["0", "0", "5", "x", "y", "2", "2", "0", "10", "2"]);
416 }
417
418 #[test]
419 fn a_table_that_is_not_runs_csv_is_refused() {
420 assert!(matches!(
421 summary("run_id,config_id\n0,0\n"),
422 Err(SummaryError::MissingColumn { column: "block" })
423 ));
424 let short = RUNS.replace("5,1,1,1,12,", "5,1,1,12,");
425 let error = summary(&short).expect_err("a short record");
426 assert!(matches!(
427 error,
428 SummaryError::FieldCount {
429 record_number: 7,
430 found: 17,
431 expected: 18
432 }
433 ));
434 assert_eq!(error.to_string(), "record 7 of runs.csv has 17 fields, expected 18");
435 let bad = RUNS.replace("ok,steps,10,64,1,2,5000,4,", "ok,steps,ten,64,1,2,5000,4,");
436 assert!(
437 matches!(summary(&bad), Err(SummaryError::BadField { record_number: 5, column, .. }) if column == "ticks")
438 );
439 }
440}