Skip to main content

henad_core/explore/
stop.rs

1//! Stop conditions, each ending a run at the first sample where one stat column passes a threshold.
2//!
3//! A condition reads `<column> <comparator> <threshold>`, as in `Infected <= 0`.
4
5use std::fmt;
6use std::str::FromStr;
7
8use crate::explore::reducer::names_a_stat;
9use crate::export::StatColumns;
10use crate::view::StatDescriptor;
11
12/// Test between a value and a threshold.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Comparator {
15    /// Less than, written `<`.
16    Less,
17    /// Less than or equal to, written `<=`.
18    LessOrEqual,
19    /// Greater than, written `>`.
20    Greater,
21    /// Greater than or equal to, written `>=`.
22    GreaterOrEqual,
23    /// Equal to, written `==`.
24    Equal,
25    /// Not equal to, written `!=`.
26    NotEqual,
27}
28
29impl Comparator {
30    /// Every comparator, each two-character symbol before the one-character symbol it starts with.
31    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    /// Returns the comparator's symbol, as in `<=`.
41    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    /// Returns whether `value` compares to `threshold` as the comparator specifies, by IEEE 754 rules.
53    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/// A comparator and its threshold, as in `<=10`.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct Comparison {
68    /// Test between a value and [`Self::threshold`].
69    pub comparator: Comparator,
70    /// Value a sample is compared with.
71    pub threshold: f64,
72}
73
74impl Comparison {
75    /// Returns whether `value` passes the comparison. A NaN value never passes, under any comparator.
76    pub fn holds(self, value: f64) -> bool {
77        !value.is_nan() && self.comparator.compare(value, self.threshold)
78    }
79
80    /// Checks that the threshold is finite, as a threshold parsed with [`FromStr`] always is.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`ComparisonError::BadThreshold`] for a threshold that is infinite or NaN.
85    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    /// Writes the comparator and the threshold with no space between, as in `<=10`.
98    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    /// Reads a comparator followed by a finite threshold, with optional spaces around the comparator and the threshold.
107    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/// Text that cannot be parsed as a [`Comparison`], or a threshold that is not finite.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum ComparisonError {
130    /// Text that does not start with a comparator.
131    MissingComparator {
132        /// Text as written, after trimming.
133        raw: String,
134    },
135    /// A threshold that is not a finite number.
136    BadThreshold {
137        /// Threshold as written, after trimming, or as `f64`'s `Display` writes it.
138        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/// A stop condition as written, with its column given as text.
157#[derive(Debug, Clone, PartialEq)]
158pub struct StopSpec {
159    /// Stat column name, or a bare vector or histogram label for its magnitude or total.
160    pub column: String,
161    /// Test that the column's value has to pass to end the run.
162    pub comparison: Comparison,
163    /// First tick at which the condition can end a run.
164    pub min_tick: u64,
165}
166
167impl StopSpec {
168    /// Reads `condition`, such as `Infected <= 0`, to be checked from `min_tick` on.
169    ///
170    /// The comparator is the last run of `<`, `>`, `=` and `!` characters, and the column is the text before it,
171    /// trimmed. A column name can then hold spaces and comparator characters, as in `Agents (k=3) <= 0.5`.
172    /// A threshold holds no comparator character, so a condition written by [`StopSpec`] is parsed back into the same
173    /// spec.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`StopError::MissingColumn`] for a condition that starts with its comparator, and
178    /// [`StopError::Comparison`] for one with no comparator or a threshold that is not a finite number.
179    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    /// Checks that the threshold is finite, as [`StopSpec::parse`] does for a condition written as text.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`StopError::NonFiniteThreshold`] for a threshold that is infinite or NaN.
207    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    /// Checks the column against the stat labels a model declares, as [`ReducerSpec::check_label`] does.
216    ///
217    /// [`ReducerSpec::check_label`]: crate::explore::reducer::ReducerSpec::check_label
218    ///
219    /// # Errors
220    ///
221    /// Returns [`StopError::UnknownColumn`] for a column that does not start with a stat label.
222    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    /// Writes the condition as [`StopSpec::parse`] reads it, as in `Infected <= 0`, without the minimum tick.
236    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/// A stop condition that cannot be read or bound.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum StopError {
245    /// A condition with nothing before its comparator.
246    MissingColumn {
247        /// Condition as written.
248        raw: String,
249    },
250    /// A condition whose comparison cannot be read.
251    Comparison {
252        /// Condition as written.
253        raw: String,
254        /// Reason the comparison cannot be read.
255        source: ComparisonError,
256    },
257    /// A condition built with a threshold that is infinite or NaN.
258    NonFiniteThreshold {
259        /// Condition as [`StopSpec`]'s `Display` writes it.
260        raw: String,
261    },
262    /// A column that no stat series produces.
263    UnknownColumn {
264        /// Column as written in the condition.
265        column: String,
266        /// Stat labels the model declares, or the column names once a build provides them.
267        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/// A stop condition bound to one column of a stat layout.
305#[derive(Debug, Clone, Copy, PartialEq)]
306pub struct StopCondition {
307    column: usize,
308    comparison: Comparison,
309    min_tick: u64,
310}
311
312impl StopCondition {
313    /// Binds `spec` to its column of `columns`.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`StopError::UnknownColumn`] for a column that [`StatColumns::resolve`] cannot find.
318    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    /// Stat column the condition reads.
333    pub fn column(&self) -> usize {
334        self.column
335    }
336
337    /// Returns whether the sample `row`, taken at `tick`, ends the run.
338    ///
339    /// `row` holds a value per stat column.
340    ///
341    /// # Panics
342    ///
343    /// Panics when `row` holds no value at [`Self::column`].
344    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    /// A condition on a label holding a comparator character splits at the comparator after the label. Otherwise a
380    /// spec recorded with a stop condition on that label could not be read back.
381    #[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    /// The fragment an error quotes is trimmed, and a missing comparator is reported as missing.
463    #[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    /// A comparison built outside [`std::str::FromStr`] can hold an infinite threshold. Unchecked, it would hold at
474    /// the first sample, and its spec written back as text could not be parsed.
475    #[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}