Skip to main content

comnum_sim/
stopping.rs

1//! When a condition has run enough trials.
2//!
3//! A [`Stop`] rule is evaluated after every batch of trials of a condition, on the
4//! merged metrics. The usual rules of error-rate estimation:
5//!
6//! - [`Stop::max_trials`]: a fixed budget of trials (the default is 100);
7//! - [`Stop::min_errors`]: at least `k` errors of an error rate, the classical rule of
8//!   BER simulations (with `k = 100` the relative standard deviation of the estimate is
9//!   about 10 %); it needs an error rate (a mean has no errors: use
10//!   [`Stop::precision`]);
11//! - [`Stop::precision`]: the half-width of the confidence interval of a metric below a
12//!   fraction of its value;
13//! - [`Stop::min_trials`]: a lower bound on the trials, to combine with the others;
14//! - [`Stop::max_time`]: a wall-time budget per condition (the only rule that depends
15//!   on the machine, and therefore breaks the thread-count independence of the
16//!   results).
17//!
18//! Rules combine with [`Stop::or`] (any of them) and [`Stop::and`] (all of them): a
19//! typical BER rule is `min_errors("ber", 200).or(max_trials(10⁵))`, which stops at 200
20//! errors or after 10⁵ trials when the BER is too low to reach them.
21//!
22//! The runner checks the rule against the metrics of the first batch of every
23//! condition ([`Stop::check_metrics`]): a target on a metric that the scenario does not
24//! record, or `min_errors` on a mean, would never be met and is an error. It also ends
25//! the last batch at the bound of the rule on the trials ([`Stop::trial_limit`]), so
26//! that `max_trials(1000)` runs 1000 trials whatever the batch size.
27//!
28//! # Examples
29//! ```
30//! use comnum_sim::stopping::{Stop, Progress};
31//! use comnum_sim::scenario::Record;
32//! use std::time::Duration;
33//! let rule = Stop::min_errors("ber", 100).or(Stop::max_trials(1000)).and(Stop::min_trials(10));
34//! let mut rec = Record::new();
35//! rec.errors("ber", 150, 10_000);
36//! let p = Progress { condition: 0, trials: 5, record: &rec, elapsed: Duration::ZERO };
37//! assert!(!rule.is_met(&p));            // enough errors but fewer than 10 trials
38//! let p = Progress { trials: 12, ..p };
39//! assert!(rule.is_met(&p));
40//! assert_eq!(rule.to_string(), "((errors(ber) ≥ 100 or max_trials(1000)) and min_trials(10))");
41//! ```
42
43use crate::scenario::Record;
44use crate::stats::Estimate;
45use comnum_core::{Error, Result};
46use std::fmt;
47use std::time::Duration;
48
49/// The state of a condition seen by the stopping rule and the callbacks.
50#[derive(Clone, Copy, Debug)]
51pub struct Progress<'a> {
52    /// Index of the condition in the grid.
53    pub condition: usize,
54    /// Trials run so far.
55    pub trials: u64,
56    /// Metrics merged over these trials.
57    pub record: &'a Record,
58    /// Wall time spent on the condition.
59    pub elapsed: Duration,
60}
61
62/// A stopping rule.
63#[derive(Clone, Debug, PartialEq)]
64pub enum Stop {
65    /// At most this many trials: met once they have run (a cap, printed `max_trials(n)`).
66    MaxTrials(u64),
67    /// Never before this many trials (use with [`Stop::and`]; met once they have run,
68    /// as [`Stop::MaxTrials`], but printed `min_trials(n)`).
69    MinTrials(u64),
70    /// At least `errors` errors of the error rate `metric` (never met by a mean).
71    MinErrors {
72        /// The error-rate metric.
73        metric: String,
74        /// The number of errors.
75        errors: u64,
76    },
77    /// Relative half-width of the confidence interval of `metric` at most `relative`.
78    Precision {
79        /// The metric.
80        metric: String,
81        /// The relative half-width.
82        relative: f64,
83        /// The confidence level of the interval.
84        confidence: f64,
85    },
86    /// At least this much wall time on the condition.
87    MaxTime(Duration),
88    /// Any of the rules.
89    Any(Vec<Stop>),
90    /// All of the rules.
91    All(Vec<Stop>),
92}
93
94impl Stop {
95    /// Stop after `n` trials.
96    pub fn max_trials(n: u64) -> Stop {
97        Stop::MaxTrials(n)
98    }
99
100    /// Never stop before `n` trials.
101    pub fn min_trials(n: u64) -> Stop {
102        Stop::MinTrials(n)
103    }
104
105    /// Stop after `errors` errors of the error rate `metric`.
106    pub fn min_errors(metric: &str, errors: u64) -> Stop {
107        Stop::MinErrors { metric: metric.to_string(), errors }
108    }
109
110    /// Stop when the 95 % interval of `metric` is within `±relative` of its value.
111    pub fn precision(metric: &str, relative: f64) -> Stop {
112        Stop::precision_at(metric, relative, 0.95)
113    }
114
115    /// Stop when the interval of level `confidence` of `metric` is within `±relative` of
116    /// its value (the runner refuses a level outside $(0, 1)$, see [`check_confidence`]).
117    ///
118    /// # Examples
119    /// ```
120    /// use comnum_sim::stopping::Stop;
121    /// assert_eq!(Stop::precision_at("snr_db", 0.01, 0.99).to_string(), "precision(snr_db) ≤ 0.01 at 0.99");
122    /// ```
123    pub fn precision_at(metric: &str, relative: f64, confidence: f64) -> Stop {
124        Stop::Precision { metric: metric.to_string(), relative, confidence }
125    }
126
127    /// Stop after `d` of wall time on the condition.
128    pub fn max_time(d: Duration) -> Stop {
129        Stop::MaxTime(d)
130    }
131
132    /// Either rule.
133    pub fn or(self, other: Stop) -> Stop {
134        Stop::Any(vec![self, other])
135    }
136
137    /// Both rules.
138    pub fn and(self, other: Stop) -> Stop {
139        Stop::All(vec![self, other])
140    }
141
142    /// Whether the rule is met.
143    pub fn is_met(&self, p: &Progress<'_>) -> bool {
144        match self {
145            Stop::MaxTrials(n) | Stop::MinTrials(n) => p.trials >= *n,
146            Stop::MinErrors { metric, errors } => {
147                matches!(p.record.get(metric), Some(Estimate::Rate(r)) if r.errors >= *errors)
148            }
149            Stop::Precision { metric, relative, confidence } => {
150                p.record.get(metric).is_some_and(|e| e.relative_precision(*confidence) <= *relative)
151            }
152            Stop::MaxTime(d) => p.elapsed >= *d,
153            Stop::Any(v) => v.iter().any(|s| s.is_met(p)),
154            Stop::All(v) => v.iter().all(|s| s.is_met(p)),
155        }
156    }
157
158    /// Whether the rule can stop at all (a rule made only of lower bounds would run
159    /// forever).
160    pub fn is_bounded(&self) -> bool {
161        match self {
162            Stop::MaxTrials(_) | Stop::MaxTime(_) => true,
163            Stop::MinTrials(_) | Stop::MinErrors { .. } | Stop::Precision { .. } => false,
164            Stop::Any(v) => v.iter().any(Stop::is_bounded),
165            Stop::All(v) => v.iter().all(Stop::is_bounded),
166        }
167    }
168
169    /// The number of trials at which the rule is met whatever the metrics and the
170    /// time, if there is one: `n` for [`Stop::max_trials`] and [`Stop::min_trials`], the
171    /// smallest bound of an [`Stop::or`], the largest of an [`Stop::and`] whose rules
172    /// all have one. The runner ends its last batch there.
173    ///
174    /// # Examples
175    /// ```
176    /// use comnum_sim::stopping::Stop;
177    /// assert_eq!(Stop::min_errors("ber", 100).or(Stop::max_trials(1000)).trial_limit(), Some(1000));
178    /// assert_eq!(Stop::max_trials(1000).and(Stop::min_trials(10)).trial_limit(), Some(1000));
179    /// assert_eq!(Stop::min_errors("ber", 100).and(Stop::max_trials(1000)).trial_limit(), None);
180    /// ```
181    pub fn trial_limit(&self) -> Option<u64> {
182        match self {
183            Stop::MaxTrials(n) | Stop::MinTrials(n) => Some(*n),
184            Stop::MinErrors { .. } | Stop::Precision { .. } | Stop::MaxTime(_) => None,
185            Stop::Any(v) => v.iter().filter_map(Stop::trial_limit).min(),
186            Stop::All(v) => v.iter().map(Stop::trial_limit).collect::<Option<Vec<u64>>>()?.into_iter().max(),
187        }
188    }
189
190    /// The metrics the rule targets, with whether the target needs an error rate.
191    fn targets<'a>(&'a self, out: &mut Vec<(&'a str, bool)>) {
192        match self {
193            Stop::MinErrors { metric, .. } => out.push((metric, true)),
194            Stop::Precision { metric, .. } => out.push((metric, false)),
195            Stop::MaxTrials(_) | Stop::MinTrials(_) | Stop::MaxTime(_) => {}
196            Stop::Any(v) | Stop::All(v) => v.iter().for_each(|s| s.targets(out)),
197        }
198    }
199
200    /// Check the confidence levels of the precision targets of the rule
201    /// ([`check_confidence`]).
202    ///
203    /// # Errors
204    /// [`Error::Config`] for a level outside $(0, 1)$.
205    pub fn check_confidences(&self) -> Result<()> {
206        match self {
207            Stop::Precision { confidence, .. } => check_confidence(*confidence, &format!("the stopping rule `{self}`")),
208            Stop::Any(v) | Stop::All(v) => v.iter().try_for_each(Stop::check_confidences),
209            Stop::MaxTrials(_) | Stop::MinTrials(_) | Stop::MinErrors { .. } | Stop::MaxTime(_) => Ok(()),
210        }
211    }
212
213    /// Check that every target of the rule can be met by the metrics of `record` (the
214    /// record of a first batch of trials).
215    ///
216    /// # Errors
217    /// [`Error::Config`] for a target on a metric that `record` does not hold (the
218    /// message names the recorded ones), or [`Stop::min_errors`] on a mean.
219    ///
220    /// # Examples
221    /// ```
222    /// use comnum_sim::stopping::Stop;
223    /// use comnum_sim::scenario::Record;
224    /// let mut rec = Record::new();
225    /// rec.errors("ber", 3, 1000);
226    /// rec.value("snr_db", 9.5);
227    /// assert!(Stop::min_errors("ber", 100).or(Stop::max_trials(10)).check_metrics(&rec).is_ok());
228    /// let e = Stop::min_errors("bler", 100).or(Stop::max_trials(10)).check_metrics(&rec).unwrap_err();
229    /// assert!(e.to_string().contains("bler") && e.to_string().contains("ber, snr_db"));
230    /// assert!(Stop::min_errors("snr_db", 100).or(Stop::max_trials(10)).check_metrics(&rec).is_err());
231    /// ```
232    pub fn check_metrics(&self, record: &Record) -> Result<()> {
233        let mut targets = Vec::new();
234        self.targets(&mut targets);
235        for (metric, needs_rate) in targets {
236            if !record.knows(metric) {
237                let names: Vec<&str> = record.iter().map(|(k, _)| k.as_str()).collect();
238                return Err(Error::Config(format!(
239                    "the stopping rule `{self}` targets the metric `{metric}`, which is not recorded (recorded: {}): it would never be met",
240                    if names.is_empty() { "none".to_string() } else { names.join(", ") }
241                )));
242            }
243            if needs_rate && matches!(record.get(metric), Some(Estimate::Mean(_))) {
244                return Err(Error::Config(format!(
245                    "the stopping rule `{self}`: min_errors needs an error rate, and `{metric}` is a mean (use precision)"
246                )));
247            }
248        }
249        Ok(())
250    }
251}
252
253/// Check a confidence level: a probability strictly between 0 and 1 (`0.95`, not `95`),
254/// `what` naming where it was given.
255///
256/// # Errors
257/// [`Error::Config`] for a level outside $(0, 1)$ or not a number.
258///
259/// # Examples
260/// ```
261/// use comnum_sim::stopping::check_confidence;
262/// assert!(check_confidence(0.95, "confidence").is_ok());
263/// let e = check_confidence(95.0, "confidence").unwrap_err().to_string();
264/// assert!(e.contains("between 0 and 1"), "{e}");
265/// ```
266pub fn check_confidence(c: f64, what: &str) -> Result<()> {
267    if c > 0.0 && c < 1.0 {
268        Ok(())
269    } else {
270        Err(Error::Config(format!("{what}: the confidence level must be between 0 and 1 (0.95 for 95 %), got {c}")))
271    }
272}
273
274impl fmt::Display for Stop {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Stop::MaxTrials(n) => write!(f, "max_trials({n})"),
278            Stop::MinTrials(n) => write!(f, "min_trials({n})"),
279            Stop::MinErrors { metric, errors } => write!(f, "errors({metric}) ≥ {errors}"),
280            Stop::Precision { metric, relative, confidence } => {
281                write!(f, "precision({metric}) ≤ {relative} at {confidence}")
282            }
283            Stop::MaxTime(d) => write!(f, "time ≥ {:.1} s", d.as_secs_f64()),
284            Stop::Any(v) => {
285                write!(f, "({})", v.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(" or "))
286            }
287            Stop::All(v) => {
288                write!(f, "({})", v.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(" and "))
289            }
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn a_target_on_a_metric_not_recorded_is_an_error() {
300        let mut rec = Record::new();
301        rec.errors("ber", 1, 100);
302        rec.value("snr_db", f64::INFINITY); // dropped, but recorded
303        let rule = Stop::min_errors("ber", 10).or(Stop::max_trials(5));
304        assert!(rule.check_metrics(&rec).is_ok());
305        assert!(Stop::precision("snr_db", 0.1).or(Stop::max_trials(5)).check_metrics(&rec).is_ok());
306        let e = Stop::precision("evm", 0.1).or(Stop::max_trials(5)).check_metrics(&rec).unwrap_err().to_string();
307        assert!(e.contains("`evm`") && e.contains("recorded: ber"), "{e}");
308        let e = Stop::min_errors("ber", 1).or(Stop::max_trials(5)).check_metrics(&Record::new()).unwrap_err();
309        assert!(e.to_string().contains("recorded: none"), "{e}");
310        assert!(Stop::max_trials(5).check_metrics(&Record::new()).is_ok());
311    }
312
313    #[test]
314    fn min_errors_on_a_mean_is_an_error_and_never_met() {
315        let mut rec = Record::new();
316        rec.values("snr_db", [1.0; 50]);
317        let rule = Stop::min_errors("snr_db", 10);
318        let e = rule.check_metrics(&rec).unwrap_err().to_string();
319        assert!(e.contains("is a mean"), "{e}");
320        let p = Progress { condition: 0, trials: 50, record: &rec, elapsed: Duration::ZERO };
321        assert!(!rule.is_met(&p));
322    }
323
324    #[test]
325    fn the_trial_limit_of_combined_rules() {
326        assert_eq!(Stop::max_trials(7).trial_limit(), Some(7));
327        assert_eq!(Stop::max_time(Duration::from_secs(1)).trial_limit(), None);
328        assert_eq!(Stop::max_trials(7).or(Stop::max_trials(3)).trial_limit(), Some(3));
329        assert_eq!(Stop::max_time(Duration::from_secs(1)).or(Stop::max_trials(3)).trial_limit(), Some(3));
330        assert_eq!(Stop::precision("x", 0.1).and(Stop::min_trials(4)).or(Stop::max_trials(9)).trial_limit(), Some(9));
331        assert_eq!(Stop::max_time(Duration::from_secs(1)).and(Stop::max_trials(3)).trial_limit(), None);
332    }
333}