1use 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
14const BYTE_ORDER_MARK: char = '\u{feff}';
16
17pub 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
81fn 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
107fn table_level(
109 slot: FactorSlot,
110 field: &str,
111 params: &[ParamDescriptor],
112) -> Result<FactorLevel, DesignTableValueError> {
113 match slot {
114 FactorSlot::Param(index) => parse_value(¶ms[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#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum DesignTableError {
128 Csv(CsvError),
130 NoHeader,
132 NoRows,
134 UnknownColumn {
136 column: String,
138 known: Vec<String>,
140 },
141 DuplicateColumn {
143 column: String,
145 },
146 Width {
148 row: usize,
150 expected: usize,
152 found: usize,
154 },
155 Value {
157 row: usize,
159 column: String,
161 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#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum DesignTableValueError {
208 Param(ValueError),
210 Tick {
212 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 fn configs(text: &str) -> Result<Vec<Config>, DesignTableError> {
272 let factors = read_table(text, ¶ms(), &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 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(¶m.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", ¶ms(), &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}