Skip to main content

cove_bench/
stats.rs

1//! What a series of timings says, and whether two series differ.
2//!
3//! [Issue #179](https://github.com/myuon/cove/issues/179) records that this
4//! repository could not write the sentence a refactor most wants to write —
5//! "no statistically meaningful regression" — because `cove-bench` reported
6//! `{min, mean, max}` and nothing else. Three numbers are not a claim; they
7//! are three numbers a reader eyeballs against a band they remember.
8//!
9//! # Why the median and the quartiles
10//!
11//! A wall-time series has a floor and no ceiling. The machine cannot run the
12//! benchmark faster than the machine runs it, and it can always run it slower
13//! — a descheduled turn, a migration between cores, a neighbour waking up. So
14//! the failure mode of a benchmark timing is a sample that is too *large*, and
15//! the mean is the statistic that moves furthest when one arrives. The median
16//! does not move at all until half the samples are affected, which is the
17//! property that makes it the right summary of a series taken on a machine
18//! that is not perfectly quiet.
19//!
20//! That is the argument from the shape of the failure, and it is the one to
21//! rely on. The argument from skew is weaker than it looks, and it is recorded
22//! here because it was checked rather than assumed. The only distributions
23//! this repository has written down are the nine rows of the
24//! calling-convention matrix, taken at nine samples a row on a quiet machine
25//! and reported as `{median, min, max}`. That table measured the backend
26//! ADR 0034 deleted and went with it; `docs/VM_ARCHITECTURE.md` restates the
27//! counts below, and the table itself is in git history at commit `6e90085`.
28//! If those series were reliably right-skewed then `max - median` would
29//! exceed `median - min` on most of them. It exceeds it on three rows, falls
30//! short on five, and ties on one. So "benchmark timings are right-skewed" is
31//! not a claim this data supports and is not what justifies the median here.
32//! What justifies it is that one bad sample must not be able to move the
33//! number a decision is made on — and that argument does not need the skew,
34//! because a statistic robust to an outlier in either direction is what a
35//! decision wants either way.
36//!
37//! Two caveats on that check, because it is weaker than a real one. Three
38//! order statistics are not a distribution, and nine samples put both
39//! extremes deep in the tails where they are noisiest. A stronger check is
40//! now possible and was not before: `wall_ns.samples` records every timing, so
41//! whoever next takes a run on a quiet machine can look at the actual shape
42//! rather than at its extremes.
43//!
44//! The mean, the minimum and the maximum are all still reported. The mean
45//! because [ADR 0012](../../../docs/adr/0012-performance-gate-and-native-backend.md)
46//! says wall time is reported as `{min, mean, max}` and a reader of that
47//! format must keep finding it; the extremes because they are the cheapest
48//! way to see that a series went wrong, and a summary that hides the one
49//! run that took four times as long is worse than no summary.
50//!
51//! # Why a comparison, and not just a spread
52//!
53//! A spread on one run says how noisy that run was. It does not say whether
54//! this build is slower than that build, which is the actual question, and
55//! [issue #126](https://github.com/myuon/cove/issues/126) is the reason it
56//! has to be asked against a *fixed commit* rather than against the parent:
57//! three changes each individually inside the noise summed to 19%. A
58//! comparison against whatever ran last cannot see that, and a comparison
59//! against a recorded baseline can.
60//!
61//! So [`Comparison`] takes the baseline's samples and this run's samples and
62//! answers with the shift between their medians and an interval around it.
63//! The verdict is read off the interval: an interval that excludes zero is a
64//! difference that cleared the noise, and one that contains zero is not — and
65//! in that case the interval's own width is the honest bound on what the
66//! change could have cost, which is the number to quote instead of claiming
67//! there was no effect.
68
69use std::collections::BTreeMap;
70
71/// How many samples a side needs before a comparison will call anything.
72///
73/// Six, and the reason is exact rather than a rule of thumb. The
74/// distribution-free interval for a median is a pair of order statistics, and
75/// the widest one available is the whole range: `[min, max]` fails to cover
76/// the true median only when every sample falls on the same side of it, which
77/// for `n` samples has probability `2 * (1/2)^n`. Asking for 95% confidence
78/// therefore asks for `2^(n-1) >= 20`, which first holds at `n = 6`. Below
79/// six samples there is no 95% statement to be made about one median, let
80/// alone about the difference of two, so a comparison that made one would be
81/// inventing it.
82///
83/// This is a floor and not a recommendation. `docs/VM_ARCHITECTURE.md` takes
84/// its tables at fifteen.
85pub const MIN_SAMPLES: usize = 6;
86
87/// How many resamples the interval below is built from.
88///
89/// Ten thousand is enough that the 2.5th and 97.5th percentiles of the
90/// resampled statistic are stable to well under a tenth of a percent, and it
91/// costs microseconds at the sample counts this harness produces.
92const RESAMPLES: usize = 10_000;
93
94/// The confidence the reported interval carries.
95pub const CONFIDENCE: f64 = 0.95;
96
97/// The seed the resampling starts from.
98///
99/// Fixed, so that the same two series always produce the same interval and
100/// the same verdict. A tool that answered differently on a rerun of
101/// identical data would be asking to be rerun until it agreed.
102const SEED: u64 = 0x0000_0C0F_FEE5_EED0;
103
104/// A series of samples and the order statistics read off it.
105///
106/// Holds the samples twice: sorted, because everything below is an order
107/// statistic, and in the order the run took them, because that is what the
108/// report carries and a baseline is only useful if the samples themselves
109/// survive into it — a summary cannot be compared against a later run with
110/// anything better than arithmetic on summaries.
111///
112/// The two copies exist for a reason a sorted one alone cannot serve.
113/// [Issue #205](https://github.com/myuon/cove/issues/205) asks whether a
114/// run-to-run disagreement is drift *within* a suite or *between* two of
115/// them, and a sorted array cannot answer it: whether the slow samples were
116/// the first three or the last three is exactly the information sorting
117/// throws away. Nothing in this file reads the run order — every statistic
118/// below is an order statistic and the bootstrap resamples with replacement
119/// — so keeping it costs one vector and buys a question that could not be
120/// asked before.
121pub struct Stats {
122    sorted: Vec<u64>,
123    /// The same samples, in the order the run took them. Reported; not read.
124    taken: Vec<u64>,
125    mean: u64,
126}
127
128impl Stats {
129    /// Summarizes `samples`, which must not be empty.
130    pub fn of(samples: &[u64]) -> Stats {
131        assert!(!samples.is_empty(), "a series has at least one sample");
132        let taken = samples.to_vec();
133        let mut sorted = taken.clone();
134        sorted.sort_unstable();
135        let sum: u128 = sorted.iter().map(|&n| u128::from(n)).sum();
136        let mean = (sum / sorted.len() as u128) as u64;
137        Stats {
138            sorted,
139            taken,
140            mean,
141        }
142    }
143
144    /// The samples, in ascending order.
145    pub fn samples(&self) -> &[u64] {
146        &self.sorted
147    }
148
149    pub fn min(&self) -> u64 {
150        self.sorted[0]
151    }
152
153    pub fn max(&self) -> u64 {
154        self.sorted[self.sorted.len() - 1]
155    }
156
157    /// The arithmetic mean, truncated. Kept because ADR 0012 names it.
158    pub fn mean(&self) -> u64 {
159        self.mean
160    }
161
162    pub fn median(&self) -> f64 {
163        quantile(&self.sorted, 0.5)
164    }
165
166    pub fn p25(&self) -> f64 {
167        quantile(&self.sorted, 0.25)
168    }
169
170    pub fn p75(&self) -> f64 {
171        quantile(&self.sorted, 0.75)
172    }
173
174    /// The interquartile range: the width of the middle half of the series.
175    ///
176    /// This is the spread to read. Unlike `max - min` it does not grow just
177    /// because the series got longer and so had more chances to catch one
178    /// bad sample.
179    pub fn iqr(&self) -> f64 {
180        self.p75() - self.p25()
181    }
182
183    /// The summary, as a JSON object.
184    ///
185    /// `min`, `mean` and `max` are first and keep their names, because ADR
186    /// 0012 describes this object as `{min, mean, max}` and a reader written
187    /// against that description must keep working.
188    pub fn to_json(&self) -> String {
189        format!(
190            "{{\"min\":{},\"mean\":{},\"max\":{},\"p25\":{:.1},\"median\":{:.1},\"p75\":{:.1},\"iqr\":{:.1}}}",
191            self.min(),
192            self.mean(),
193            self.max(),
194            self.p25(),
195            self.median(),
196            self.p75(),
197            self.iqr(),
198        )
199    }
200
201    /// The summary with every sample beside it.
202    ///
203    /// What makes a recorded run a baseline rather than a memory of one: a
204    /// comparison needs the samples, not a summary of them, because the
205    /// interval it reports is built by resampling them.
206    ///
207    /// **In the order the run took them**, which is strictly more than a
208    /// sorted array says and costs a reader who wanted the sorted one a
209    /// `sort`. Nothing that compares two runs is affected — every statistic
210    /// here is an order statistic and [`Comparison::of`] sorts what it is
211    /// given — and what it buys is that a reader can see *when* in a series a
212    /// slow sample arrived, which is the difference between a machine that
213    /// drifted and a benchmark that is noisy.
214    pub fn to_json_with_samples(&self) -> String {
215        let mut json = self.to_json();
216        json.pop();
217        let samples: Vec<String> = self.taken.iter().map(u64::to_string).collect();
218        json.push_str(&format!(",\"samples\":[{}]}}", samples.join(",")));
219        json
220    }
221}
222
223/// The `p`th quantile of an ascending series, by linear interpolation
224/// between the two order statistics that bracket it.
225///
226/// This is the definition NumPy and R use by default (R's type 7), chosen
227/// because it is the one a reader who reaches for another tool to check this
228/// one will get from it. At `p = 0.5` it is the ordinary median: the middle
229/// sample of an odd series, the average of the two middle samples of an even
230/// one.
231pub fn quantile(sorted: &[u64], p: f64) -> f64 {
232    assert!(!sorted.is_empty(), "a series has at least one sample");
233    let h = (sorted.len() - 1) as f64 * p;
234    let lower = h.floor() as usize;
235    let upper = h.ceil() as usize;
236    let below = sorted[lower] as f64;
237    let above = sorted[upper] as f64;
238    below + (h - lower as f64) * (above - below)
239}
240
241/// What a comparison concluded.
242#[derive(Clone, Copy, PartialEq, Eq, Debug)]
243pub enum Verdict {
244    /// One side has fewer than [`MIN_SAMPLES`] samples, so no interval is
245    /// available and nothing is claimed. The shift is still reported, as a
246    /// number to look at rather than a number to act on.
247    Underpowered,
248    /// The interval contains zero. The difference, whatever it is, is not
249    /// separable from the noise of these two runs.
250    InsideTheNoise,
251    /// The interval lies entirely above zero: this run is slower.
252    Regression,
253    /// The interval lies entirely below zero: this run is faster.
254    Improvement,
255}
256
257impl Verdict {
258    pub fn as_str(self) -> &'static str {
259        match self {
260            Verdict::Underpowered => "underpowered",
261            Verdict::InsideTheNoise => "inside the noise",
262            Verdict::Regression => "regression",
263            Verdict::Improvement => "improvement",
264        }
265    }
266}
267
268/// This run's median against a baseline's, and how sure of the difference the
269/// two series allow anyone to be.
270#[derive(Clone, Copy, Debug)]
271pub struct Comparison {
272    pub baseline_median_ns: f64,
273    pub median_ns: f64,
274    /// The shift between the two medians, as a percentage of the baseline's.
275    /// Positive is slower.
276    pub delta_pct: f64,
277    /// The interval around `delta_pct`, at [`CONFIDENCE`].
278    pub low_pct: f64,
279    pub high_pct: f64,
280    pub verdict: Verdict,
281}
282
283impl Comparison {
284    /// Compares two series of wall times.
285    ///
286    /// The interval is a percentile bootstrap on the relative shift between
287    /// the medians: resample each side with replacement, take the two
288    /// medians, and record `(current - baseline) / baseline`. The 2.5th and
289    /// 97.5th percentiles of ten thousand such records are the interval.
290    ///
291    /// A bootstrap rather than a `t` test because nothing here is normal and
292    /// the statistic is a median; a percentile bootstrap rather than a rank
293    /// test because what a refactor needs is not only "is there a
294    /// difference" but "how large could it be", and the interval answers
295    /// both while a `p` value answers only the first.
296    pub fn of(baseline: &[u64], current: &[u64]) -> Comparison {
297        let mut base_sorted = baseline.to_vec();
298        base_sorted.sort_unstable();
299        let mut current_sorted = current.to_vec();
300        current_sorted.sort_unstable();
301
302        let baseline_median_ns = quantile(&base_sorted, 0.5);
303        let median_ns = quantile(&current_sorted, 0.5);
304        let delta_pct = if baseline_median_ns > 0.0 {
305            100.0 * (median_ns - baseline_median_ns) / baseline_median_ns
306        } else {
307            0.0
308        };
309
310        // A zero baseline median has no relative shift to report, and a
311        // series shorter than the floor has no interval; both are
312        // `Underpowered`, which is this type's way of saying "the number
313        // beside this is not a claim".
314        if baseline.len() < MIN_SAMPLES || current.len() < MIN_SAMPLES || baseline_median_ns <= 0.0
315        {
316            return Comparison {
317                baseline_median_ns,
318                median_ns,
319                delta_pct,
320                low_pct: f64::NAN,
321                high_pct: f64::NAN,
322                verdict: Verdict::Underpowered,
323            };
324        }
325
326        let mut shifts = Vec::with_capacity(RESAMPLES);
327        let mut rng = Rng::new(SEED);
328        let mut base_draw = vec![0u64; base_sorted.len()];
329        let mut current_draw = vec![0u64; current_sorted.len()];
330        for _ in 0..RESAMPLES {
331            for slot in base_draw.iter_mut() {
332                *slot = base_sorted[rng.below(base_sorted.len())];
333            }
334            for slot in current_draw.iter_mut() {
335                *slot = current_sorted[rng.below(current_sorted.len())];
336            }
337            base_draw.sort_unstable();
338            current_draw.sort_unstable();
339            let b = quantile(&base_draw, 0.5);
340            let c = quantile(&current_draw, 0.5);
341            shifts.push(if b > 0.0 { 100.0 * (c - b) / b } else { 0.0 });
342        }
343        shifts.sort_by(|a, b| a.partial_cmp(b).expect("no sample is NaN"));
344
345        let tail = (1.0 - CONFIDENCE) / 2.0;
346        let low_pct = percentile_f64(&shifts, tail);
347        let high_pct = percentile_f64(&shifts, 1.0 - tail);
348        let verdict = if low_pct > 0.0 {
349            Verdict::Regression
350        } else if high_pct < 0.0 {
351            Verdict::Improvement
352        } else {
353            Verdict::InsideTheNoise
354        };
355
356        Comparison {
357            baseline_median_ns,
358            median_ns,
359            delta_pct,
360            low_pct,
361            high_pct,
362            verdict,
363        }
364    }
365
366    /// The comparison as one line of the harness's JSON output.
367    ///
368    /// `kind` is `comparison` rather than the kind of the row compared, so
369    /// that a reader filtering on `kind` keeps finding exactly the rows it
370    /// was finding; `of` says which kind this line is about.
371    pub fn to_json(self, benchmark: &str, of: &str, backend: &str) -> String {
372        let interval = if self.verdict == Verdict::Underpowered {
373            "\"ci_low_pct\":null,\"ci_high_pct\":null".to_string()
374        } else {
375            format!(
376                "\"ci_low_pct\":{:.2},\"ci_high_pct\":{:.2}",
377                self.low_pct, self.high_pct
378            )
379        };
380        format!(
381            "{{\"benchmark\":\"{}\",\"kind\":\"comparison\",\"of\":\"{}\",\"backend\":\"{}\",\"baseline_median_ns\":{:.1},\"median_ns\":{:.1},\"delta_pct\":{:.2},{},\"confidence\":{},\"verdict\":\"{}\"}}",
382            benchmark,
383            of,
384            backend,
385            self.baseline_median_ns,
386            self.median_ns,
387            self.delta_pct,
388            interval,
389            CONFIDENCE,
390            self.verdict.as_str(),
391        )
392    }
393}
394
395/// The `p`th percentile of an ascending series of `f64`, by the same
396/// interpolation [`quantile`] uses.
397fn percentile_f64(sorted: &[f64], p: f64) -> f64 {
398    let h = (sorted.len() - 1) as f64 * p;
399    let lower = h.floor() as usize;
400    let upper = h.ceil() as usize;
401    sorted[lower] + (h - lower as f64) * (sorted[upper] - sorted[lower])
402}
403
404/// SplitMix64, which is four lines and needs no dependency.
405///
406/// The resampling wants a stream that is the same every time and does not
407/// want cryptographic quality; this is the standard seeding generator for
408/// exactly that job.
409struct Rng(u64);
410
411impl Rng {
412    fn new(seed: u64) -> Rng {
413        Rng(seed)
414    }
415
416    fn next_u64(&mut self) -> u64 {
417        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
418        let mut z = self.0;
419        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
420        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
421        z ^ (z >> 31)
422    }
423
424    /// A uniform index below `n`. The modulo bias is under one part in 2^58
425    /// for the sample counts this harness produces.
426    fn below(&mut self, n: usize) -> usize {
427        (self.next_u64() % n as u64) as usize
428    }
429}
430
431// ------------------------------------------------------------ the baseline
432
433/// Which row of a report a comparison is about.
434///
435/// The benchmark, the kind of measurement, and the backend, which is exactly
436/// what ADR 0019 requires every number here to carry — two rows that agree on
437/// all three are the same measurement taken twice, and no two rows of one
438/// report agree on all three.
439#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
440pub struct RowKey {
441    pub benchmark: String,
442    pub kind: String,
443    pub backend: String,
444}
445
446/// A previous run of this harness, read back as the samples it recorded.
447pub struct Baseline {
448    rows: BTreeMap<RowKey, Vec<u64>>,
449}
450
451impl Baseline {
452    /// Reads a baseline from this harness's own JSONL output.
453    ///
454    /// This parses the format this file writes and nothing else. It is not a
455    /// JSON parser and does not pretend to be one: it looks for the three
456    /// string fields that name a row and for the sample array inside
457    /// `wall_ns`, and it skips any line that does not carry all four. Lines
458    /// that legitimately do not — an `unsupported` refusal, a
459    /// `trace_overhead` ratio, a `comparison` from an earlier run — are
460    /// therefore skipped rather than treated as errors.
461    ///
462    /// A baseline written by an older build, before `samples` was reported,
463    /// parses as empty. That is the wanted behaviour: comparing against
464    /// summaries would mean inventing the spread that made them.
465    pub fn parse(text: &str) -> Result<Baseline, String> {
466        let mut rows = BTreeMap::new();
467        for line in text.lines() {
468            let line = line.trim();
469            if line.is_empty() {
470                continue;
471            }
472            let (Some(benchmark), Some(kind), Some(backend)) = (
473                string_field(line, "benchmark"),
474                string_field(line, "kind"),
475                string_field(line, "backend"),
476            ) else {
477                continue;
478            };
479            let Some(samples) = wall_samples(line) else {
480                continue;
481            };
482            if samples.is_empty() {
483                continue;
484            }
485            rows.insert(
486                RowKey {
487                    benchmark,
488                    kind,
489                    backend,
490                },
491                samples,
492            );
493        }
494        if rows.is_empty() {
495            return Err(
496                "no rows with a `wall_ns.samples` array; a baseline must be the JSON output \
497of a `cove-bench` run new enough to record its samples"
498                    .to_string(),
499            );
500        }
501        Ok(Baseline { rows })
502    }
503
504    /// How many rows the baseline carries.
505    pub fn len(&self) -> usize {
506        self.rows.len()
507    }
508
509    /// The samples recorded for one row, if the baseline has it.
510    ///
511    /// A row the baseline does not have is not an error: benchmarks are added
512    /// and the VM learns to run ones it previously refused, and a baseline
513    /// taken before either is still a baseline for every row it does have.
514    pub fn samples(&self, benchmark: &str, kind: &str, backend: &str) -> Option<&[u64]> {
515        self.rows
516            .get(&RowKey {
517                benchmark: benchmark.to_string(),
518                kind: kind.to_string(),
519                backend: backend.to_string(),
520            })
521            .map(Vec::as_slice)
522    }
523}
524
525/// The value of `"<name>":"..."` in a line this harness wrote.
526fn string_field(line: &str, name: &str) -> Option<String> {
527    let needle = format!("\"{name}\":\"");
528    let start = line.find(&needle)? + needle.len();
529    let rest = &line[start..];
530    let end = rest.find('"')?;
531    Some(rest[..end].to_string())
532}
533
534/// The `samples` array inside this line's `wall_ns` object.
535///
536/// Anchored on `wall_ns` so that a future field carrying samples of its own
537/// cannot be read as if it were the wall time.
538fn wall_samples(line: &str) -> Option<Vec<u64>> {
539    let start = line.find("\"wall_ns\":{")?;
540    let rest = &line[start..];
541    let end = rest.find('}')?;
542    let object = &rest[..end];
543    let needle = "\"samples\":[";
544    let list_start = object.find(needle)? + needle.len();
545    let list = &object[list_start..];
546    let list_end = list.find(']')?;
547    let list = &list[..list_end];
548    if list.trim().is_empty() {
549        return Some(Vec::new());
550    }
551    list.split(',')
552        .map(|item| item.trim().parse::<u64>().ok())
553        .collect()
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    /// A deterministic series with a known median and a known spread.
561    ///
562    /// Every test below runs on synthetic data, and that is deliberate: the
563    /// question these tests ask is whether the estimator recovers an answer
564    /// that is known in advance, and a real timing series has no known
565    /// answer to recover. A machine under load cannot make any of these
566    /// fail.
567    fn series(centre: f64, spread_pct: f64, n: usize, seed: u64) -> Vec<u64> {
568        let mut rng = Rng::new(seed);
569        (0..n)
570            .map(|_| {
571                // Uniform on [-spread, +spread] about the centre.
572                let unit = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
573                let jitter = (unit * 2.0 - 1.0) * spread_pct / 100.0;
574                (centre * (1.0 + jitter)).round() as u64
575            })
576            .collect()
577    }
578
579    #[test]
580    fn quantiles_are_the_textbook_ones() {
581        let odd: Vec<u64> = (1..=9).collect();
582        assert_eq!(quantile(&odd, 0.5), 5.0);
583        assert_eq!(quantile(&odd, 0.25), 3.0);
584        assert_eq!(quantile(&odd, 0.75), 7.0);
585        assert_eq!(quantile(&odd, 0.0), 1.0);
586        assert_eq!(quantile(&odd, 1.0), 9.0);
587
588        let even: Vec<u64> = vec![1, 2, 3, 4];
589        assert_eq!(quantile(&even, 0.5), 2.5);
590        assert_eq!(quantile(&even, 0.25), 1.75);
591        assert_eq!(quantile(&even, 0.75), 3.25);
592
593        let one = vec![42];
594        assert_eq!(quantile(&one, 0.5), 42.0);
595        assert_eq!(quantile(&one, 0.25), 42.0);
596    }
597
598    #[test]
599    fn the_median_ignores_the_slow_run_and_the_mean_does_not() {
600        // Nine samples at 100, and one run that took ten times as long --
601        // the shape of the failure a wall-time series actually has.
602        let clean: Vec<u64> = vec![100; 9];
603        let polluted: Vec<u64> = vec![100, 100, 100, 100, 100, 100, 100, 100, 100, 1000];
604
605        assert_eq!(Stats::of(&clean).median(), 100.0);
606        assert_eq!(Stats::of(&polluted).median(), 100.0);
607
608        assert_eq!(Stats::of(&clean).mean(), 100);
609        assert_eq!(Stats::of(&polluted).mean(), 190);
610    }
611
612    /// Issue #205 needs to know *when* a slow sample arrived, and a sorted
613    /// array cannot say. The order statistics stay what they were.
614    #[test]
615    fn the_report_carries_the_series_in_the_order_it_was_taken() {
616        let stats = Stats::of(&[30, 10, 20]);
617        assert!(
618            stats
619                .to_json_with_samples()
620                .contains("\"samples\":[30,10,20]"),
621            "{}",
622            stats.to_json_with_samples()
623        );
624        assert_eq!(stats.median(), 20.0);
625        assert_eq!(stats.min(), 10);
626        assert_eq!(stats.max(), 30);
627    }
628
629    #[test]
630    fn stats_keeps_what_adr_0012_named() {
631        let stats = Stats::of(&[30, 10, 20]);
632        assert_eq!(stats.min(), 10);
633        assert_eq!(stats.max(), 30);
634        assert_eq!(stats.mean(), 20);
635        assert_eq!(stats.samples(), &[10, 20, 30]);
636        let json = stats.to_json();
637        assert!(
638            !json.contains("samples"),
639            "the summary alone carries no samples: {json}"
640        );
641        for field in ["\"min\":10", "\"mean\":20", "\"max\":30"] {
642            assert!(json.contains(field), "{json} is missing {field}");
643        }
644        for field in ["\"p25\":", "\"median\":", "\"p75\":", "\"iqr\":"] {
645            assert!(json.contains(field), "{json} is missing {field}");
646        }
647    }
648
649    #[test]
650    fn the_iqr_is_the_middle_half() {
651        // A tight middle with one outlier on each side: the range doubles,
652        // the interquartile range does not move.
653        let tight: Vec<u64> = vec![100, 101, 102, 103, 104, 105, 106, 107, 108];
654        let mut wide = tight.clone();
655        wide[0] = 1;
656        wide[8] = 500;
657        assert_eq!(Stats::of(&tight).iqr(), Stats::of(&wide).iqr());
658        assert!(Stats::of(&wide).max() - Stats::of(&wide).min() > 400);
659    }
660
661    #[test]
662    fn a_run_against_itself_is_inside_the_noise() {
663        let baseline = series(100_000.0, 6.0, 15, 1);
664        let current = series(100_000.0, 6.0, 15, 2);
665        let comparison = Comparison::of(&baseline, &current);
666        assert_eq!(comparison.verdict, Verdict::InsideTheNoise);
667        assert!(
668            comparison.low_pct <= 0.0 && comparison.high_pct >= 0.0,
669            "the interval {:.2}..{:.2} should contain zero",
670            comparison.low_pct,
671            comparison.high_pct
672        );
673    }
674
675    #[test]
676    fn a_twenty_percent_regression_clears_the_band() {
677        let baseline = series(100_000.0, 6.0, 15, 3);
678        let current = series(120_000.0, 6.0, 15, 4);
679        let comparison = Comparison::of(&baseline, &current);
680        assert_eq!(comparison.verdict, Verdict::Regression);
681        assert!(
682            (comparison.delta_pct - 20.0).abs() < 5.0,
683            "the shift should be near 20%, was {:.2}%",
684            comparison.delta_pct
685        );
686        assert!(
687            comparison.low_pct > 0.0,
688            "the interval {:.2}..{:.2} should exclude zero",
689            comparison.low_pct,
690            comparison.high_pct
691        );
692        assert!(
693            comparison.low_pct <= comparison.delta_pct
694                && comparison.delta_pct <= comparison.high_pct,
695            "the interval {:.2}..{:.2} should bracket the shift {:.2}",
696            comparison.low_pct,
697            comparison.high_pct,
698            comparison.delta_pct
699        );
700    }
701
702    #[test]
703    fn a_twenty_percent_improvement_clears_the_band_the_other_way() {
704        let baseline = series(100_000.0, 6.0, 15, 5);
705        let current = series(80_000.0, 6.0, 15, 6);
706        let comparison = Comparison::of(&baseline, &current);
707        assert_eq!(comparison.verdict, Verdict::Improvement);
708        assert!(comparison.high_pct < 0.0);
709    }
710
711    #[test]
712    fn a_one_percent_shift_under_six_percent_noise_is_not_a_claim() {
713        // This is the case issue #126 is made of: a change smaller than the
714        // band `docs/VM_ARCHITECTURE.md` records for `arith`. Fifteen
715        // samples a side must not call it.
716        let baseline = series(100_000.0, 6.0, 15, 7);
717        let current = series(101_000.0, 6.0, 15, 8);
718        let comparison = Comparison::of(&baseline, &current);
719        assert_eq!(comparison.verdict, Verdict::InsideTheNoise);
720    }
721
722    #[test]
723    fn enough_samples_resolve_what_few_cannot() {
724        // The same 3% shift under the same 6% noise: not resolvable at
725        // fifteen samples a side, resolvable at two hundred. This is what
726        // `--iterations` buys, and it is why the flag is the answer to "how
727        // many runs" rather than a second concept beside it.
728        let few = Comparison::of(
729            &series(100_000.0, 6.0, 15, 9),
730            &series(103_000.0, 6.0, 15, 10),
731        );
732        assert_eq!(few.verdict, Verdict::InsideTheNoise);
733
734        let many = Comparison::of(
735            &series(100_000.0, 6.0, 200, 11),
736            &series(103_000.0, 6.0, 200, 12),
737        );
738        assert_eq!(many.verdict, Verdict::Regression);
739    }
740
741    #[test]
742    fn below_the_floor_nothing_is_claimed() {
743        // One sample a side and a 50% difference: still not a claim, because
744        // one sample has no spread and an interval built from it would be a
745        // fabrication. This is the shape `--iterations 1` has, which is what
746        // CI runs.
747        let one = Comparison::of(&[100_000], &[150_000]);
748        assert_eq!(one.verdict, Verdict::Underpowered);
749        assert!((one.delta_pct - 50.0).abs() < 1e-9);
750        assert!(one.low_pct.is_nan() && one.high_pct.is_nan());
751
752        // Five is still below the floor; six is the first that is not.
753        let five = Comparison::of(
754            &series(100_000.0, 6.0, 5, 13),
755            &series(150_000.0, 6.0, 5, 14),
756        );
757        assert_eq!(five.verdict, Verdict::Underpowered);
758        let six = Comparison::of(
759            &series(100_000.0, 6.0, 6, 15),
760            &series(150_000.0, 6.0, 6, 16),
761        );
762        assert_eq!(six.verdict, Verdict::Regression);
763    }
764
765    #[test]
766    fn the_floor_is_where_a_ninety_five_percent_statement_first_exists() {
767        // `[min, max]` misses the median with probability 2 * (1/2)^n. The
768        // floor is the smallest `n` for which that is at most 5%.
769        let covers = |n: u32| 1.0 - 2.0 * 0.5_f64.powi(n as i32) >= CONFIDENCE;
770        assert!(!covers(MIN_SAMPLES as u32 - 1));
771        assert!(covers(MIN_SAMPLES as u32));
772    }
773
774    #[test]
775    fn the_same_data_always_gives_the_same_answer() {
776        let baseline = series(100_000.0, 6.0, 15, 17);
777        let current = series(104_000.0, 6.0, 15, 18);
778        let first = Comparison::of(&baseline, &current);
779        let second = Comparison::of(&baseline, &current);
780        assert_eq!(first.verdict, second.verdict);
781        assert_eq!(first.low_pct.to_bits(), second.low_pct.to_bits());
782        assert_eq!(first.high_pct.to_bits(), second.high_pct.to_bits());
783    }
784
785    #[test]
786    fn order_does_not_change_the_answer() {
787        let baseline = series(100_000.0, 6.0, 15, 19);
788        let mut shuffled = baseline.clone();
789        shuffled.reverse();
790        let current = series(112_000.0, 6.0, 15, 20);
791        let straight = Comparison::of(&baseline, &current);
792        let reversed = Comparison::of(&shuffled, &current);
793        assert_eq!(straight.low_pct.to_bits(), reversed.low_pct.to_bits());
794        assert_eq!(straight.high_pct.to_bits(), reversed.high_pct.to_bits());
795    }
796
797    #[test]
798    fn a_baseline_round_trips_through_the_report_format() {
799        let samples = vec![7u64, 3, 5, 9];
800        let stats = Stats::of(&samples);
801        let line = format!(
802            "{{\"benchmark\":\"field\",\"kind\":\"vm\",\"backend\":\"vm\",\"iterations\":4,\"wall_ns\":{},\"fuel_spent\":1,\"ok\":true}}",
803            stats.to_json_with_samples()
804        );
805        let baseline = Baseline::parse(&line).expect("the line parses");
806        assert_eq!(baseline.len(), 1);
807        // In the order the run took them, not sorted: the report carries the
808        // series as it happened, and a comparison sorts what it is given.
809        assert_eq!(
810            baseline.samples("field", "vm", "vm"),
811            Some([7u64, 3, 5, 9].as_slice())
812        );
813        assert_eq!(baseline.samples("field", "interpreter", "ast"), None);
814    }
815
816    #[test]
817    fn lines_without_samples_are_skipped_rather_than_failing() {
818        let text = "\
819{\"benchmark\":\"pure\",\"kind\":\"unsupported\",\"backend\":\"vm\",\"what\":\"a `spawn`\",\"ok\":false}
820{\"benchmark\":\"pure\",\"kind\":\"trace_overhead\",\"backend\":\"vm\",\"untraced_wall_ns\":1,\"traced_wall_ns\":2,\"overhead_ratio\":2.0}
821
822{\"benchmark\":\"pure\",\"kind\":\"vm\",\"backend\":\"vm\",\"wall_ns\":{\"min\":1,\"mean\":2,\"max\":3,\"samples\":[1,2,3]},\"ok\":true}";
823        let baseline = Baseline::parse(text).expect("one row parses");
824        assert_eq!(baseline.len(), 1);
825        assert_eq!(
826            baseline.samples("pure", "vm", "vm"),
827            Some([1u64, 2, 3].as_slice())
828        );
829    }
830
831    #[test]
832    fn a_baseline_from_a_build_that_recorded_no_samples_is_refused() {
833        let old = "{\"benchmark\":\"pure\",\"kind\":\"vm\",\"backend\":\"vm\",\"iterations\":5,\"wall_ns\":{\"min\":1,\"mean\":2,\"max\":3},\"ok\":true}";
834        assert!(Baseline::parse(old).is_err());
835        assert!(Baseline::parse("").is_err());
836    }
837
838    #[test]
839    fn an_underpowered_comparison_reports_no_interval() {
840        let json = Comparison::of(&[10], &[20]).to_json("pure", "vm", "vm");
841        assert!(json.contains("\"verdict\":\"underpowered\""));
842        assert!(json.contains("\"ci_low_pct\":null"));
843        assert!(json.contains("\"kind\":\"comparison\""));
844        assert!(json.contains("\"of\":\"vm\""));
845    }
846}