1use std::fmt;
6use std::str::FromStr;
7
8use crate::explore::reducer::names_a_stat;
9use crate::export::StatColumns;
10use crate::view::StatDescriptor;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Comparator {
15 Less,
17 LessOrEqual,
19 Greater,
21 GreaterOrEqual,
23 Equal,
25 NotEqual,
27}
28
29impl Comparator {
30 const PARSE_ORDER: [Self; 6] = [
32 Self::LessOrEqual,
33 Self::Less,
34 Self::GreaterOrEqual,
35 Self::Greater,
36 Self::Equal,
37 Self::NotEqual,
38 ];
39
40 pub fn as_str(self) -> &'static str {
42 match self {
43 Self::Less => "<",
44 Self::LessOrEqual => "<=",
45 Self::Greater => ">",
46 Self::GreaterOrEqual => ">=",
47 Self::Equal => "==",
48 Self::NotEqual => "!=",
49 }
50 }
51
52 pub fn compare(self, value: f64, threshold: f64) -> bool {
54 match self {
55 Self::Less => value < threshold,
56 Self::LessOrEqual => value <= threshold,
57 Self::Greater => value > threshold,
58 Self::GreaterOrEqual => value >= threshold,
59 Self::Equal => value == threshold,
60 Self::NotEqual => value != threshold,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct Comparison {
68 pub comparator: Comparator,
70 pub threshold: f64,
72}
73
74impl Comparison {
75 pub fn holds(self, value: f64) -> bool {
77 !value.is_nan() && self.comparator.compare(value, self.threshold)
78 }
79
80 pub fn check(self) -> Result<(), ComparisonError> {
86 if self.threshold.is_finite() {
87 Ok(())
88 } else {
89 Err(ComparisonError::BadThreshold {
90 raw: self.threshold.to_string(),
91 })
92 }
93 }
94}
95
96impl fmt::Display for Comparison {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 write!(f, "{}{}", self.comparator.as_str(), self.threshold)
100 }
101}
102
103impl FromStr for Comparison {
104 type Err = ComparisonError;
105
106 fn from_str(raw: &str) -> Result<Self, Self::Err> {
108 let text = raw.trim();
109 let (comparator, threshold) = Comparator::PARSE_ORDER
110 .iter()
111 .find_map(|&comparator| {
112 text.strip_prefix(comparator.as_str())
113 .map(|rest| (comparator, rest.trim()))
114 })
115 .ok_or_else(|| ComparisonError::MissingComparator { raw: text.to_owned() })?;
116 let threshold = threshold
117 .parse::<f64>()
118 .ok()
119 .filter(|number| number.is_finite())
120 .ok_or_else(|| ComparisonError::BadThreshold {
121 raw: threshold.to_owned(),
122 })?;
123 Ok(Self { comparator, threshold })
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum ComparisonError {
130 MissingComparator {
132 raw: String,
134 },
135 BadThreshold {
137 raw: String,
139 },
140}
141
142impl fmt::Display for ComparisonError {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 Self::MissingComparator { raw } if raw.is_empty() => {
146 write!(f, "missing comparator, expected <, <=, >, >=, == or !=")
147 }
148 Self::MissingComparator { raw } => write!(f, "'{raw}' does not start with <, <=, >, >=, == or !="),
149 Self::BadThreshold { raw } => write!(f, "threshold '{raw}' is not a finite number"),
150 }
151 }
152}
153
154impl std::error::Error for ComparisonError {}
155
156#[derive(Debug, Clone, PartialEq)]
158pub struct StopSpec {
159 pub column: String,
161 pub comparison: Comparison,
163 pub min_tick: u64,
165}
166
167impl StopSpec {
168 pub fn parse(condition: &str, min_tick: u64) -> Result<Self, StopError> {
180 let is_comparator = |character: char| matches!(character, '<' | '>' | '=' | '!');
181 let split = condition.rfind(is_comparator).map_or(condition.len(), |last| {
182 condition[..last].trim_end_matches(is_comparator).len()
183 });
184 let (column, comparison) = condition.split_at(split);
185 let column = column.trim();
186 if column.is_empty() {
187 return Err(StopError::MissingColumn {
188 raw: condition.to_owned(),
189 });
190 }
191 let comparison = comparison.parse().map_err(|source| StopError::Comparison {
192 raw: condition.to_owned(),
193 source,
194 })?;
195 Ok(Self {
196 column: column.to_owned(),
197 comparison,
198 min_tick,
199 })
200 }
201
202 pub fn check_threshold(&self) -> Result<(), StopError> {
208 if self.comparison.threshold.is_finite() {
209 Ok(())
210 } else {
211 Err(StopError::NonFiniteThreshold { raw: self.to_string() })
212 }
213 }
214
215 pub fn check_label(&self, stats: &[StatDescriptor]) -> Result<(), StopError> {
223 if names_a_stat(&self.column, stats) {
224 Ok(())
225 } else {
226 Err(StopError::UnknownColumn {
227 column: self.column.clone(),
228 known: stats.iter().map(|stat| stat.label.to_owned()).collect(),
229 })
230 }
231 }
232}
233
234impl fmt::Display for StopSpec {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 let Comparison { comparator, threshold } = self.comparison;
238 write!(f, "{} {} {threshold}", self.column, comparator.as_str())
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum StopError {
245 MissingColumn {
247 raw: String,
249 },
250 Comparison {
252 raw: String,
254 source: ComparisonError,
256 },
257 NonFiniteThreshold {
259 raw: String,
261 },
262 UnknownColumn {
264 column: String,
266 known: Vec<String>,
268 },
269}
270
271impl fmt::Display for StopError {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 match self {
274 Self::MissingColumn { raw } => write!(f, "stop condition '{raw}' names no column"),
275 Self::Comparison { raw, .. } => {
276 write!(
277 f,
278 "invalid stop condition '{raw}', expected COLUMN COMPARATOR THRESHOLD"
279 )
280 }
281 Self::NonFiniteThreshold { raw } => {
282 write!(f, "stop condition '{raw}' has a threshold that is not a finite number")
283 }
284 Self::UnknownColumn { column, known } => {
285 write!(
286 f,
287 "unknown stat column '{column}', expected one of {}",
288 known.join(", ")
289 )
290 }
291 }
292 }
293}
294
295impl std::error::Error for StopError {
296 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
297 match self {
298 Self::Comparison { source, .. } => Some(source),
299 Self::MissingColumn { .. } | Self::NonFiniteThreshold { .. } | Self::UnknownColumn { .. } => None,
300 }
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq)]
306pub struct StopCondition {
307 column: usize,
308 comparison: Comparison,
309 min_tick: u64,
310}
311
312impl StopCondition {
313 pub fn bind(spec: &StopSpec, columns: &StatColumns) -> Result<Self, StopError> {
319 let column = columns.resolve(&spec.column).ok_or_else(|| StopError::UnknownColumn {
320 column: spec.column.clone(),
321 known: (0..columns.len())
322 .map(|column| columns.name(column).to_owned())
323 .collect(),
324 })?;
325 Ok(Self {
326 column,
327 comparison: spec.comparison,
328 min_tick: spec.min_tick,
329 })
330 }
331
332 pub fn column(&self) -> usize {
334 self.column
335 }
336
337 pub fn holds(&self, tick: u64, row: &[f64]) -> bool {
345 tick >= self.min_tick && self.comparison.holds(row[self.column])
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::{Comparator, Comparison, ComparisonError, StopCondition, StopError, StopSpec};
352 use crate::export::StatColumns;
353 use crate::helpers::stat;
354 use crate::view::StatDescriptor;
355
356 const COLOR: [u8; 4] = [0, 0, 0, 255];
357
358 fn stop(condition: &str) -> StopSpec {
359 StopSpec::parse(condition, 0).expect("a well-formed condition")
360 }
361
362 #[test]
363 fn a_stop_condition_parses_labels_with_spaces() {
364 let spec = stop("Giant Component Share >= 0.5");
365 assert_eq!(spec.column, "Giant Component Share");
366 assert_eq!(
367 spec.comparison,
368 Comparison {
369 comparator: Comparator::GreaterOrEqual,
370 threshold: 0.5
371 }
372 );
373 assert_eq!(spec.to_string(), "Giant Component Share >= 0.5");
374 let tight = stop(" Infected<=0 ");
375 assert_eq!((tight.column.as_str(), tight.comparison.threshold), ("Infected", 0.0));
376 assert_eq!(tight.to_string(), "Infected <= 0");
377 }
378
379 #[test]
382 fn a_label_holding_comparator_characters_reads_back() {
383 for label in ["Agents (k=3)", "R>1 cells", "a<b", "Not!", "x >= y"] {
384 for comparator in Comparator::PARSE_ORDER {
385 let spec = StopSpec {
386 column: label.to_owned(),
387 comparison: Comparison {
388 comparator,
389 threshold: -0.5,
390 },
391 min_tick: 3,
392 };
393 assert_eq!(StopSpec::parse(&spec.to_string(), 3), Ok(spec.clone()), "{spec}");
394 }
395 }
396 assert_eq!(stop("R>1 cells<=0.5").column, "R>1 cells");
397 }
398
399 #[test]
400 fn every_comparator_parses() {
401 for comparator in Comparator::PARSE_ORDER {
402 let spec = stop(&format!("Infected {} -3.5", comparator.as_str()));
403 assert_eq!(spec.comparison.comparator, comparator);
404 assert_eq!(spec.comparison.threshold, -3.5);
405 let comparison = Comparison {
406 comparator,
407 threshold: 10.0,
408 };
409 assert_eq!(comparison.to_string().parse(), Ok(comparison), "{comparison}");
410 }
411 let holds = |comparator, value| {
412 Comparison {
413 comparator,
414 threshold: 1.0,
415 }
416 .holds(value)
417 };
418 assert!(holds(Comparator::Less, 0.5) && !holds(Comparator::Less, 1.0));
419 assert!(holds(Comparator::LessOrEqual, 1.0) && !holds(Comparator::LessOrEqual, 1.5));
420 assert!(holds(Comparator::Greater, 1.5) && !holds(Comparator::Greater, 1.0));
421 assert!(holds(Comparator::GreaterOrEqual, 1.0) && !holds(Comparator::GreaterOrEqual, 0.5));
422 assert!(holds(Comparator::Equal, 1.0) && !holds(Comparator::Equal, 0.5));
423 assert!(holds(Comparator::NotEqual, 0.5) && !holds(Comparator::NotEqual, 1.0));
424 }
425
426 #[test]
427 fn a_malformed_condition_is_refused() {
428 assert_eq!(
429 StopSpec::parse("<= 0", 0),
430 Err(StopError::MissingColumn { raw: "<= 0".to_owned() })
431 );
432 let comparison_error = |condition: &str| match StopSpec::parse(condition, 0) {
433 Err(StopError::Comparison { source, .. }) => source,
434 other => panic!("{condition} gave {other:?}"),
435 };
436 assert!(matches!(
437 comparison_error("Infected 0"),
438 ComparisonError::MissingComparator { .. }
439 ));
440 assert!(matches!(
441 comparison_error("Infected => 0"),
442 ComparisonError::MissingComparator { .. }
443 ));
444 assert!(matches!(
445 comparison_error("Infected = 0"),
446 ComparisonError::MissingComparator { .. }
447 ));
448 assert_eq!(
449 comparison_error("Infected <= many"),
450 ComparisonError::BadThreshold { raw: "many".to_owned() }
451 );
452 assert!(matches!(
453 comparison_error("Infected < NaN"),
454 ComparisonError::BadThreshold { .. }
455 ));
456 assert_eq!(
457 StopSpec::parse("Infected <= x", 0).map_err(|error| error.to_string()),
458 Err("invalid stop condition 'Infected <= x', expected COLUMN COMPARATOR THRESHOLD".to_owned())
459 );
460 }
461
462 #[test]
464 fn a_missing_comparator_quotes_the_trimmed_text() {
465 assert_eq!(
466 " 0 ".parse::<Comparison>(),
467 Err(ComparisonError::MissingComparator { raw: "0".to_owned() })
468 );
469 let error = "".parse::<Comparison>().expect_err("no comparator");
470 assert_eq!(error.to_string(), "missing comparator, expected <, <=, >, >=, == or !=");
471 }
472
473 #[test]
476 fn a_threshold_that_is_not_finite_is_refused() {
477 for threshold in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
478 let spec = StopSpec {
479 column: "Infected".to_owned(),
480 comparison: Comparison {
481 comparator: Comparator::LessOrEqual,
482 threshold,
483 },
484 min_tick: 0,
485 };
486 assert_eq!(
487 spec.check_threshold(),
488 Err(StopError::NonFiniteThreshold { raw: spec.to_string() })
489 );
490 assert!(StopSpec::parse(&spec.to_string(), 0).is_err(), "{spec}");
491 }
492 let infinite = StopSpec {
493 comparison: Comparison {
494 comparator: Comparator::LessOrEqual,
495 threshold: f64::INFINITY,
496 },
497 ..stop("Infected <= 0")
498 };
499 assert_eq!(
500 infinite.check_threshold().map_err(|error| error.to_string()),
501 Err("stop condition 'Infected <= inf' has a threshold that is not a finite number".to_owned())
502 );
503 assert_eq!(stop("Infected <= 0").check_threshold(), Ok(()));
504 }
505
506 #[test]
507 fn nan_never_satisfies_a_stop_condition() {
508 for comparator in Comparator::PARSE_ORDER {
509 let comparison = Comparison {
510 comparator,
511 threshold: 0.0,
512 };
513 assert!(!comparison.holds(f64::NAN), "{comparison}");
514 }
515 let columns = StatColumns::plan(&[stat("Infected", 0.0, COLOR)]);
516 let condition = StopCondition::bind(&stop("Infected != 5"), &columns).expect("the column exists");
517 assert!(!condition.holds(10, &[f64::NAN]));
518 assert!(condition.holds(10, &[4.0]));
519 }
520
521 #[test]
522 fn a_stop_is_not_checked_before_its_min_tick() {
523 let columns = StatColumns::plan(&[stat("Recovered", 0.0, COLOR), stat("Infected", 0.0, COLOR)]);
524 let spec = StopSpec::parse("Infected <= 0", 20).expect("a well-formed condition");
525 let condition = StopCondition::bind(&spec, &columns).expect("the column exists");
526 assert_eq!(condition.column(), 1);
527 assert!(!condition.holds(0, &[5.0, 0.0]));
528 assert!(!condition.holds(19, &[5.0, 0.0]));
529 assert!(condition.holds(20, &[5.0, 0.0]));
530 assert!(!condition.holds(25, &[5.0, 1.0]));
531 }
532
533 #[test]
534 fn a_stop_over_an_unknown_column_is_refused() {
535 let stats = [StatDescriptor::new("Infected", COLOR)];
536 assert_eq!(stop("Infected <= 0").check_label(&stats), Ok(()));
537 assert!(stop("Recovered <= 0").check_label(&stats).is_err());
538 let columns = StatColumns::plan(&[stat("Infected", 0.0, COLOR)]);
539 let error = StopCondition::bind(&stop("Infected.x <= 0"), &columns).expect_err("no such column");
540 assert_eq!(
541 error.to_string(),
542 "unknown stat column 'Infected.x', expected one of Infected"
543 );
544 }
545}