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}