Skip to main content

henad_core/explore/
design_csv.rs

1//! Design tables, the comma-separated form of a table design.
2//!
3//! The header lists one parameter id or `action.<name>` per column, and each row after it is one config. A config
4//! keeps the fixed value of every parameter and action the table leaves out.
5
6use std::fmt;
7
8use crate::explore::factor::{Factor, FactorDomain, FactorLevel, FactorSlot};
9use crate::explore::spec::{ACTION_COLUMN_PREFIX, ActionSpec};
10use crate::explore::value::{ValueError, parse_value};
11use crate::export::csv::{CsvError, parse_records};
12use crate::params::ParamDescriptor;
13
14/// Byte order mark some spreadsheets write before the header.
15const BYTE_ORDER_MARK: char = '\u{feff}';
16
17/// Reads the design table `text` into one factor per column, whose levels are the column's values in row order.
18///
19/// A zip over the factors yields one config per row. Spaces around a field are ignored, and so are blank lines.
20///
21/// # Errors
22///
23/// Returns [`DesignTableError`] for text that is not CSV or holds no row after its header, a column that matches no
24/// parameter or action or appears twice, a row whose width differs from the header, or a value that its column
25/// rejects.
26pub fn read_table(
27    text: &str,
28    params: &[ParamDescriptor],
29    actions: &[ActionSpec],
30) -> Result<Vec<Factor>, DesignTableError> {
31    let records = parse_records(text.strip_prefix(BYTE_ORDER_MARK).unwrap_or(text)).map_err(DesignTableError::Csv)?;
32    let mut records = (1_usize..)
33        .zip(records)
34        .filter(|(_, record)| !matches!(record.as_slice(), [field] if field.trim().is_empty()));
35    let Some((_, header)) = records.next() else {
36        return Err(DesignTableError::NoHeader);
37    };
38
39    let mut factors: Vec<Factor> = Vec::with_capacity(header.len());
40    for column in &header {
41        let column = column.trim();
42        let slot = column_slot(column, params, actions)?;
43        if factors.iter().any(|factor| factor.slot == slot) {
44            return Err(DesignTableError::DuplicateColumn {
45                column: column.to_owned(),
46            });
47        }
48        factors.push(Factor {
49            slot,
50            domain: FactorDomain::Levels(Vec::new()),
51        });
52    }
53
54    let mut rows = 0;
55    for (row, record) in records {
56        if record.len() != header.len() {
57            return Err(DesignTableError::Width {
58                row,
59                expected: header.len(),
60                found: record.len(),
61            });
62        }
63        for ((factor, column), field) in factors.iter_mut().zip(&header).zip(&record) {
64            let level = table_level(factor.slot, field.trim(), params).map_err(|source| DesignTableError::Value {
65                row,
66                column: column.trim().to_owned(),
67                source,
68            })?;
69            if let FactorDomain::Levels(levels) = &mut factor.domain {
70                levels.push(level);
71            }
72        }
73        rows += 1;
74    }
75    if rows == 0 {
76        return Err(DesignTableError::NoRows);
77    }
78    Ok(factors)
79}
80
81/// Returns the slot that the column `column` writes.
82fn column_slot(
83    column: &str,
84    params: &[ParamDescriptor],
85    actions: &[ActionSpec],
86) -> Result<FactorSlot, DesignTableError> {
87    let slot = match column.strip_prefix(ACTION_COLUMN_PREFIX) {
88        Some(name) => actions
89            .iter()
90            .position(|action| action.name == name)
91            .map(FactorSlot::Action),
92        None => params
93            .iter()
94            .position(|descriptor| descriptor.id == column)
95            .map(FactorSlot::Param),
96    };
97    slot.ok_or_else(|| DesignTableError::UnknownColumn {
98        column: column.to_owned(),
99        known: params
100            .iter()
101            .map(|descriptor| descriptor.id.to_owned())
102            .chain(actions.iter().map(ActionSpec::column_name))
103            .collect(),
104    })
105}
106
107/// Returns the level that `field` sets for the factor that writes `slot`.
108fn table_level(
109    slot: FactorSlot,
110    field: &str,
111    params: &[ParamDescriptor],
112) -> Result<FactorLevel, DesignTableValueError> {
113    match slot {
114        FactorSlot::Param(index) => parse_value(&params[index].kind, field)
115            .map(FactorLevel::Param)
116            .map_err(DesignTableValueError::Param),
117        FactorSlot::Action(_) => field
118            .parse()
119            .ok()
120            .map(FactorLevel::Tick)
121            .ok_or_else(|| DesignTableValueError::Tick { raw: field.to_owned() }),
122    }
123}
124
125/// A design table that yields no configs.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum DesignTableError {
128    /// Text that is not CSV.
129    Csv(CsvError),
130    /// Text with no header.
131    NoHeader,
132    /// A header with no row after it.
133    NoRows,
134    /// A column that matches no parameter id and no `action.<name>`.
135    UnknownColumn {
136        /// Column as written in the header, after trimming.
137        column: String,
138        /// Columns the table can have, every parameter id and then every action's `action.<name>`.
139        known: Vec<String>,
140    },
141    /// A column that appears twice in the header.
142    DuplicateColumn {
143        /// Column as written in the header, after trimming.
144        column: String,
145    },
146    /// Row `row`, with `found` fields where the header has `expected`.
147    Width {
148        /// Position of the record in the text, counting from 1 with blank lines included.
149        row: usize,
150        /// Number of fields in the header.
151        expected: usize,
152        /// Number of fields in the row.
153        found: usize,
154    },
155    /// A value in row `row` that column `column` rejects.
156    Value {
157        /// Position in the text of the record holding the value, counting from 1 with blank lines included.
158        row: usize,
159        /// Column of the value, as written in the header, after trimming.
160        column: String,
161        /// Reason the column rejects the value.
162        source: DesignTableValueError,
163    },
164}
165
166impl fmt::Display for DesignTableError {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::Csv(_) => f.write_str("design table is not valid CSV"),
170            Self::NoHeader => f.write_str("design table is empty"),
171            Self::NoRows => f.write_str("design table has a header and no rows"),
172            Self::UnknownColumn { column, known } => {
173                write!(
174                    f,
175                    "unknown design table column '{column}', expected one of {}",
176                    known.join(", ")
177                )
178            }
179            Self::DuplicateColumn { column } => write!(f, "design table has column '{column}' twice"),
180            Self::Width { row, expected, found } => {
181                write!(
182                    f,
183                    "row {row} of the design table has {found} fields, expected {expected}"
184                )
185            }
186            Self::Value { row, column, .. } => write!(f, "row {row} of the design table, column '{column}'"),
187        }
188    }
189}
190
191impl std::error::Error for DesignTableError {
192    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
193        match self {
194            Self::Csv(error) => Some(error),
195            Self::Value { source, .. } => Some(source),
196            Self::NoHeader
197            | Self::NoRows
198            | Self::UnknownColumn { .. }
199            | Self::DuplicateColumn { .. }
200            | Self::Width { .. } => None,
201        }
202    }
203}
204
205/// A value of a design table that its column rejects.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum DesignTableValueError {
208    /// A value the parameter's descriptor rejects.
209    Param(ValueError),
210    /// An action tick that is not a non-negative whole number.
211    Tick {
212        /// Tick as written, after trimming.
213        raw: String,
214    },
215}
216
217impl fmt::Display for DesignTableValueError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        match self {
220            Self::Param(error) => error.fmt(f),
221            Self::Tick { raw } => write!(f, "tick '{raw}' is not a non-negative integer"),
222        }
223    }
224}
225
226impl std::error::Error for DesignTableValueError {
227    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
228        match self {
229            Self::Param(error) => error.source(),
230            Self::Tick { .. } => None,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{DesignTableError, DesignTableValueError, read_table};
238    use crate::explore::design::{Block, DesignKind, generate};
239    use crate::explore::factor::{FactorLevel, FactorSlot};
240    use crate::explore::plan::Config;
241    use crate::explore::spec::ActionSpec;
242    use crate::explore::value::format_value;
243    use crate::export::csv::escape_field;
244    use crate::helpers::{bool_param, choice_param, f32_param, u32_param};
245    use crate::params::{ParamDescriptor, ParamValue};
246
247    const NEIGHBORHOODS: &[&str] = &["moore", "von neumann, wide"];
248
249    fn params() -> Vec<ParamDescriptor> {
250        vec![
251            f32_param("rate", "Rate", 0.5, 0.0, 1.0, Some(0.01)),
252            u32_param("size", "Size", 10, 1, 100),
253            bool_param("wrap", "Wrap", true),
254            choice_param("neighborhood", "Neighborhood", NEIGHBORHOODS, 0),
255        ]
256    }
257
258    fn actions() -> Vec<ActionSpec> {
259        vec![ActionSpec::new("seed_outbreak", 40)]
260    }
261
262    fn base() -> Config {
263        Config {
264            block: 0,
265            params: params().iter().map(|param| param.kind.default_value()).collect(),
266            action_ticks: vec![40],
267        }
268    }
269
270    /// Returns the configs of the table `text`.
271    fn configs(text: &str) -> Result<Vec<Config>, DesignTableError> {
272        let factors = read_table(text, &params(), &actions())?;
273        let block = Block {
274            design: DesignKind::Table { text: text.to_owned() },
275            factors,
276            design_seed: 0,
277        };
278        Ok(generate(&block, &base()).expect("a table zips"))
279    }
280
281    /// Returns `configs` as a design table over every parameter and action.
282    fn table(configs: &[Config]) -> String {
283        let params = params();
284        let mut header: Vec<String> = params.iter().map(|param| param.id.to_owned()).collect();
285        header.extend(actions().iter().map(ActionSpec::column_name));
286        let mut lines = vec![header.join(",")];
287        for config in configs {
288            let mut fields: Vec<String> = params
289                .iter()
290                .zip(&config.params)
291                .map(|(param, value)| escape_field(&format_value(&param.kind, value)))
292                .collect();
293            fields.extend(config.action_ticks.iter().map(ToString::to_string));
294            lines.push(fields.join(","));
295        }
296        lines.join("\n")
297    }
298
299    #[test]
300    fn each_row_is_one_config() {
301        let text = "\u{feff}size, action.seed_outbreak\r\n4,10\r\n\r\n 8 , 20\r\n";
302        let configs = configs(text).expect("a valid table");
303        assert_eq!(configs.len(), 2, "the blank line is skipped");
304        assert_eq!(configs[1].params[1], ParamValue::U32(8));
305        assert_eq!(configs[1].action_ticks, [20]);
306        assert_eq!(
307            configs[0].params[0],
308            ParamValue::F32(0.5),
309            "a column left out keeps its value"
310        );
311    }
312
313    #[test]
314    fn a_design_table_round_trips() {
315        let text = "rate,neighborhood,action.seed_outbreak,wrap\n\
316                    0.1,moore,10,true\n\
317                    0.30000001,\"von neumann, wide\",0,false\n\
318                    1,1,18446744073709551615,true\n";
319        let first = configs(text).expect("a valid table");
320        assert_eq!(first[1].params[0], ParamValue::F32(0.3));
321        assert_eq!(
322            first[1].params[3],
323            ParamValue::Choice(1),
324            "a quoted option keeps its comma"
325        );
326        assert_eq!(first[2].params[3], ParamValue::Choice(1), "an option by index");
327        assert_eq!(first[2].action_ticks, [u64::MAX]);
328        let written = table(&first);
329        assert_eq!(configs(&written), Ok(first), "{written}");
330    }
331
332    #[test]
333    fn a_design_table_with_an_unknown_column_is_refused() {
334        let error = configs("rate,speed\n0.1,2\n").expect_err("no parameter 'speed'");
335        assert_eq!(
336            error.to_string(),
337            "unknown design table column 'speed', expected one of rate, size, wrap, neighborhood, action.seed_outbreak"
338        );
339        assert!(matches!(
340            configs("action.second_wave\n4\n"),
341            Err(DesignTableError::UnknownColumn { .. })
342        ));
343        assert!(
344            matches!(
345                configs("seed_outbreak\n4\n"),
346                Err(DesignTableError::UnknownColumn { .. })
347            ),
348            "an action column carries its prefix"
349        );
350    }
351
352    #[test]
353    fn a_malformed_design_table_is_refused() {
354        assert_eq!(configs(""), Err(DesignTableError::NoHeader));
355        assert_eq!(configs("rate,size\n\n"), Err(DesignTableError::NoRows));
356        assert_eq!(
357            configs("rate,rate\n0.1,0.2\n"),
358            Err(DesignTableError::DuplicateColumn {
359                column: "rate".to_owned()
360            })
361        );
362        assert_eq!(
363            configs("rate,size\n0.1,2\n0.2\n"),
364            Err(DesignTableError::Width {
365                row: 3,
366                expected: 2,
367                found: 1
368            })
369        );
370        assert!(matches!(configs("rate\n\"0.1\n"), Err(DesignTableError::Csv(_))));
371        let refused = |text: &str| match configs(text) {
372            Err(DesignTableError::Value { row, column, source }) => (row, column, source),
373            other => panic!("{text} gave {other:?}"),
374        };
375        let (row, column, source) = refused("size,rate\n4,0.1\n5,1.5\n");
376        assert_eq!((row, column.as_str()), (3, "rate"));
377        assert!(matches!(source, DesignTableValueError::Param(_)), "{source:?}");
378        let (_, column, source) = refused("action.seed_outbreak\n-4\n");
379        assert_eq!(column, "action.seed_outbreak");
380        assert_eq!(source, DesignTableValueError::Tick { raw: "-4".to_owned() });
381    }
382
383    #[test]
384    fn table_columns_name_their_slots() {
385        let factors = read_table("wrap,action.seed_outbreak\ntrue,3\n", &params(), &actions()).expect("a valid table");
386        let slots: Vec<FactorSlot> = factors.iter().map(|factor| factor.slot).collect();
387        assert_eq!(slots, [FactorSlot::Param(2), FactorSlot::Action(0)]);
388        assert_eq!(factors[1].levels(), Some(&[FactorLevel::Tick(3)][..]));
389    }
390}