Skip to main content

gam_math/
paired_timing.rs

1//! One way to measure "does A beat B", for every speed gate in this workspace.
2//!
3//! Fifteen separate timing harnesses across ten files were doing this in three
4//! different ways, and the differences decide whether a gate can tell a
5//! regression from a busy machine (issue #932, and #2470 for the duplication).
6//! Two of the fifteen interleaved the arms; thirteen did not.
7//!
8//! # Why the majority pattern cannot resolve what it asserts
9//!
10//! The `best_ns` family — thirteen copies — times each arm in a **separate
11//! call**: A is measured to completion (five rounds, minimum taken), and only
12//! then is B. Two things go wrong at once.
13//!
14//! * **The arms occupy different wall-clock windows.** Anything that drifts
15//!   between them — a neighbour job starting, a frequency ramp, cache or
16//!   branch-predictor state warmed by the first arm, first-touch page faults
17//!   amortised by the first arm — lands entirely in the ratio. Taking a minimum
18//!   rejects a transient *spike*; it does nothing about a systematic offset.
19//! * **A minimum is the order statistic most exposed to exactly that.** It is
20//!   the single most favourable draw for each arm, so a small constant advantage
21//!   to whichever arm ran second survives the minimum intact rather than
22//!   averaging out. And pairing two independently-minimised blocks throws away
23//!   the pairing that would have cancelled the drift in the first place.
24//!
25//! The consequence is not hypothetical. On the same tree, one such gate PASSED
26//! on a quiet node (whole suite 5.1 s) and FAILED at 1.62x against a 1.5x bar on
27//! a loaded one (same suite 219.1 s); another picked a **different loser on
28//! consecutive runs** of one tree, 9% then 3%. Both were then guarded off in
29//! debug builds, which hid the symptom without touching the cause: the harness
30//! cannot resolve a margin of a few percent, and most of these gates assert
31//! margins of a few percent.
32//!
33//! # What this does instead
34//!
35//! * **Interleave per repetition, not per block.** Each repetition times A and B
36//!   adjacent in time, so drift slower than one repetition is common to both
37//!   sides of that repetition's ratio and divides out.
38//! * **Randomise the order within each repetition.** If a first-versus-second
39//!   advantage exists at all, randomisation makes it cancel in expectation
40//!   instead of accruing to a fixed arm — and [`PairedTiming::first_position_bias`]
41//!   reports the residual so it is measured rather than assumed away.
42//! * **Report the distribution of PAIRED ratios**, not a ratio of aggregate
43//!   extrema. The per-repetition ratio is the quantity the claim is about; a
44//!   median of paired ratios is robust, and the spread of that distribution is
45//!   the gate's own resolution — which is the number you need in order to know
46//!   whether a 3% claim is assertable at all.
47//! * **Feed each iteration from the previous result.** A dependence chain
48//!   through `checksum` is what stops the optimizer hoisting or vectorising
49//!   across iterations; `black_box` alone permits both, and one of the replaced
50//!   harnesses relied on `black_box` with no data dependence.
51//!
52//! # `wins_fraction` is evidence, not a gate
53//!
54//! Lead a report with it: `wins = 1.00` over fifteen repetitions is a
55//! distribution-free sign test at `2^-15`, it does not depend on the resolution
56//! estimate, and that matters most exactly when the resolution estimate is the
57//! thing under suspicion. It settled a multinomial cell whose margin was only
58//! **1.6x** its own resolution — a comparison of ratio against resolution
59//! declined to certify that cell.
60//!
61//! **Do not put it in a bar.** It is a within-run confidence statement *at that
62//! host's noise level*, so it degrades in the opposite direction from the
63//! quantity it would be guarding. Measured on one 1.6% effect: `wins = 0.00` on
64//! a quiet node, `0.27 / 0.40 / 0.27` on a node ~30x noisier, and three runs of
65//! **identical code** giving `0.67 / 0.87 / 1.00` — while `median_ratio` stayed
66//! inside a 0.8% band. ANDed into a gate it can only manufacture failures on a
67//! busy runner. **Gate on `median_ratio`; report `wins` and `ratio_resolution`.**
68//!
69//! # A derived margin is sometimes zero
70//!
71//! "Derive the margin from the resolution" **cannot mean "add a margin."**
72//! Sometimes the resolution says none is warranted and the derived answer is the
73//! bar already there. A zero-margin bar that looked like a coin flip was in fact
74//! guarding a real regression at many times its own resolution, and the obvious
75//! 5% tolerance would have passed that regression silently.
76//!
77//! The converse case is just as sharp. One gate's cell sits ~1.5% below its
78//! opponent across six measurements on three nodes, with two candidate
79//! mechanisms measured and refuted; the estimator it replaced called that cell a
80//! comfortable pass at `1.043911`. **Fixing the estimator did not make the bar
81//! assertable — it made clear the old numbers were not measuring the quantity at
82//! all.** Whether such a cell keeps a strict bar is a contract decision, and it
83//! must be taken explicitly with a stated reason, never by widening a bar in the
84//! commit that measured it.
85//!
86//! # The arm must be large relative to one closure call
87//!
88//! This harness costs a closure call plus a `black_box` per iteration, and it
89//! calls the arm through a `&mut F`. That cost lands in **both** arms, so it
90//! cannot manufacture a winner on its own — with an equal per-call overhead
91//! `c`, a true ratio `b / a` is measured as `(b + c) / (a + c)`, which is
92//! monotone toward 1 and **never crosses it**.
93//!
94//! What it can do is let a *difference* in that overhead decide a small margin.
95//! The two arms are distinct closures wrapping distinct callees, so they need
96//! not inline identically, and the residual asymmetry is a fixed number of
97//! nanoseconds rather than a fraction of the arm.
98//!
99//! Measured: an SLS value/gradient/Hessian gate whose arm was **one row**
100//! (~43 ns) read `42.77 / 44.17 ns` under the old min-of-N harness and
101//! `90.25 / 87.50 ns` here — both arms roughly doubled, and the verdict changed
102//! sign. Solving `(44.17 + c) / (42.77 + c) = 0.9668` needs `c = -85 ns`, so a
103//! symmetric overhead cannot explain it; a ~4 ns asymmetry between the two
104//! closures can, because the quantity under test was only 1.4 ns.
105//!
106//! Batching that same gate to 64 rows per call (arm ~2685 ns, overhead under
107//! 2%) settles it in the opposite direction and unanimously:
108//! `median_ratio = 1.045250, wins = 1.00, resolution = 0.0092` — generated is
109//! 4.5% faster, a margin 4.9x its own resolution. The one-row reading was
110//! measuring the harness.
111//!
112//! **So make one arm call do a batch.** Every other gate migrated to this
113//! harness already did without anyone choosing it — a 512-row pass, a full
114//! Fisher sweep, a bundle — which is why they were unaffected. A single-row
115//! arm is the case that needs an explicit inner loop, sized so the per-call
116//! cost is under ~1% of the arm.
117//!
118//! [`PairedTiming::summary`] prints the per-arm `ns/iter` precisely so this is
119//! checkable: if those numbers are of the same order as a function call, the
120//! ratio is not measuring what it claims to.
121//!
122//! # Why not measure the arms separately and normalise afterwards
123//!
124//! Because it does not work, and it fails in **both** directions. `iperf2`
125//! measured this directly on `gam-solve::inner_fit_core_scaling`, a gate whose
126//! two arms genuinely cannot be interleaved — one fans out over the whole Rayon
127//! pool and the other is held serial by a guard, so external load hurts them
128//! unequally. They divided the ratio by the parallel headroom the machine was
129//! delivering at that moment, measured on an embarrassingly parallel kernel:
130//!
131//! * **Normaliser sampled once.** Headroom on four saturated cores bounced
132//!   `2.36 / 1.28 / 2.81` across three consecutive repetitions. At the `1.28`
133//!   sample a genuinely serial solve scores `1.0 / 1.28 = 0.78` and **passes** a
134//!   `0.5` bar — a false green in which the gate certifies the exact defect it
135//!   exists to catch.
136//! * **Normaliser as max over five repetitions** (the right estimator for a
137//!   capability, since interference only pushes an observed speedup down). Fixes
138//!   the false green, and then loaded runs score `0.44` and `0.52` against the
139//!   same `0.5` bar — red on working code.
140//!
141//! The underlying reason generalises past that one gate:
142//!
143//! > **A ratio whose two arms are measured at different times, on a machine
144//! > whose load moves on that timescale, cannot be normalised after the fact.**
145//! > Interleaving per repetition is not tidiness — it is what makes the arms
146//! > share machine state instead of sampling it twice.
147//!
148//! The same lane's control is the cleanest demonstration that the *measurement*
149//! rather than the *code* is what breaks: on one node, same four cores, back to
150//! back, the identical solve scored `2.91` / `3.43` idle and `1.94` under four
151//! spinners — straddling its own bar with nothing about the solver changed. A
152//! width sweep at 2/4/8/16/32 cores on dedicated allocations tracked the pool
153//! width at every width.
154//!
155//! # When the arms cannot be interleaved at all
156//!
157//! Some comparisons are between configurations that *want different machines* —
158//! different core counts, different memory pressure — and no amount of
159//! interleaving makes them share state. For those, take the confound away from
160//! the measurement instead of modelling it: `.config/nextest.toml` supports
161//!
162//! ```toml
163//! [[profile.default.overrides]]
164//! threads-required = 'num-test-threads'
165//! ```
166//!
167//! which reserves every runner slot so the test runs alone. It is already in use
168//! in this repository for exactly this reason. The general rule, which is worth
169//! more than the mechanism: **before building something to cancel an
170//! environmental confound, check whether the runner can remove the confound
171//! instead.**
172//!
173//! # Using it as a gate
174//!
175//! Assert on [`PairedTiming::median_ratio`] together with
176//! [`PairedTiming::wins_fraction`], and print [`PairedTiming::summary`] whatever
177//! the outcome. A median ratio that clears the bar while `wins_fraction` sits
178//! near 0.5 means the margin is inside the measurement's resolution and the
179//! claim is not established, however good the point estimate looks.
180//!
181//! **Lead with `wins_fraction`, not the ratio.** It is the statistic that
182//! survives someone disbelieving the rest of the output. `wins == 1.0` over `n`
183//! repetitions is a sign test at `2⁻ⁿ` — 15 repetitions is `≈3e-5` — and it is
184//! **distribution-free**: it does not depend on [`PairedTiming::ratio_resolution`]
185//! being correctly characterised, which is the one number a skeptic can
186//! reasonably question. The ratio says *how much*; `wins` says *whether*. When
187//! the first real migration onto this harness reported `median_ratio=0.938934`
188//! with `wins=0.00`, it was the `wins` that settled a question two lanes had
189//! been arguing from opposite directions.
190//!
191//! # The design lesson, for the next gate
192//!
193//! [`PairedTiming::first_position_bias`] is here because of a specific failure:
194//! a fixed-order harness cannot separate a real 6% margin from a 6%
195//! first-versus-second offset, since **both** produce a stable ratio with noisy
196//! absolutes. The pre-existing answer was to run the whole gate a second time
197//! with the arms swapped and see whether the verdict flipped — which works, but
198//! only ever yields yes/no, costs a full second measurement, and has to be
199//! redone by hand every time anyone doubts it.
200//!
201//! Randomising the order and reporting the residual **apportions** the confound
202//! instead: on the measurement above, ordering contributed 0.0001 and the code
203//! contributed 0.061. Generalising:
204//!
205//! > **Report a confound as a measured field rather than eliminating it by
206//! > argument.** An argument that a confound was controlled has to be re-made,
207//! > and re-believed, by every later reader. A field in the output is checked
208//! > once and then simply read.
209//!
210//! That is the property to copy when building the next gate here, more than any
211//! particular statistic in this module.
212
213use std::hint::black_box;
214use std::time::Instant;
215
216/// Paired per-repetition timings for two implementations of one computation.
217///
218/// `a_ns[i]` and `b_ns[i]` were measured adjacent in time within repetition `i`,
219/// in an order chosen by the repetition's coin flip. `ratios[i]` is
220/// `b_ns[i] / a_ns[i]`, so a value **above 1 means A is faster** — the same
221/// orientation as the `hand_over_production` token these gates already print.
222#[derive(Clone, Debug)]
223pub struct PairedTiming {
224    /// Nanoseconds per iteration for arm A, one entry per repetition.
225    pub a_ns: Vec<f64>,
226    /// Nanoseconds per iteration for arm B, one entry per repetition.
227    pub b_ns: Vec<f64>,
228    /// `b_ns[i] / a_ns[i]`, one entry per repetition. Above 1 ⇒ A faster.
229    pub ratios: Vec<f64>,
230    /// `true` when arm A was timed first in that repetition.
231    pub a_went_first: Vec<bool>,
232}
233
234impl PairedTiming {
235    /// Median of the paired ratios — the headline estimate.
236    ///
237    /// The median rather than the mean because a single descheduled repetition
238    /// produces an arbitrarily large outlier in one direction only, and rather
239    /// than a minimum because a minimum is the statistic that preserves a
240    /// systematic offset (see the module docs).
241    pub fn median_ratio(&self) -> f64 {
242        median(&self.ratios)
243    }
244
245    /// Fraction of repetitions in which A was faster than B.
246    ///
247    /// This is the gate's honesty check. A median ratio of 1.06 with a wins
248    /// fraction of 1.0 is a real effect; the same 1.06 with 0.55 means the
249    /// repetitions disagree with each other and the point estimate is riding on
250    /// a few draws.
251    pub fn wins_fraction(&self) -> f64 {
252        if self.ratios.is_empty() {
253            return f64::NAN;
254        }
255        let wins = self
256            .a_ns
257            .iter()
258            .zip(self.b_ns.iter())
259            .filter(|(a, b)| a < b)
260            .count();
261        wins as f64 / self.a_ns.len() as f64
262    }
263
264    /// Half the central 90% span of the paired ratios, as a fraction of the
265    /// median — the gate's own **resolution**.
266    ///
267    /// A claimed margin smaller than this is not measurable by this harness at
268    /// this repetition count, and asserting it is asserting noise. Report it
269    /// beside the margin so the comparison is visible.
270    pub fn ratio_resolution(&self) -> f64 {
271        if self.ratios.len() < 2 {
272            return f64::NAN;
273        }
274        let mut sorted = self.ratios.clone();
275        sorted.sort_by(f64::total_cmp);
276        let lo = quantile_sorted(&sorted, 0.05);
277        let hi = quantile_sorted(&sorted, 0.95);
278        let med = median(&self.ratios);
279        if !med.is_finite() || med == 0.0 {
280            return f64::NAN;
281        }
282        ((hi - lo) / 2.0 / med).abs()
283    }
284
285    /// Median ratio among repetitions where A ran first, minus the median among
286    /// those where B ran first.
287    ///
288    /// This is the diagnostic the old harnesses could not produce, because they
289    /// never varied the order. A value near zero says position does not matter
290    /// on this host; a large value says the measurement is dominated by
291    /// whichever arm goes first, and **no ordering of a non-randomised harness
292    /// would have been trustworthy**. Returns `NaN` if either group is empty.
293    pub fn first_position_bias(&self) -> f64 {
294        let a_first: Vec<f64> = self
295            .ratios
296            .iter()
297            .zip(self.a_went_first.iter())
298            .filter(|(_, first)| **first)
299            .map(|(r, _)| *r)
300            .collect();
301        let b_first: Vec<f64> = self
302            .ratios
303            .iter()
304            .zip(self.a_went_first.iter())
305            .filter(|(_, first)| !**first)
306            .map(|(r, _)| *r)
307            .collect();
308        if a_first.is_empty() || b_first.is_empty() {
309            return f64::NAN;
310        }
311        median(&a_first) - median(&b_first)
312    }
313
314    /// One line carrying everything needed to audit the verdict, including the
315    /// numbers that would reveal the verdict as unsupported.
316    pub fn summary(&self, a_label: &str, b_label: &str) -> String {
317        format!(
318            "{a_label}={:.2} ns/iter {b_label}={:.2} ns/iter \
319             median_ratio={:.6} wins={:.2} resolution={:.4} position_bias={:.4} reps={}",
320            median(&self.a_ns),
321            median(&self.b_ns),
322            self.median_ratio(),
323            self.wins_fraction(),
324            self.ratio_resolution(),
325            self.first_position_bias(),
326            self.ratios.len(),
327        )
328    }
329}
330
331/// Time two implementations against each other, interleaved per repetition with
332/// a randomised order.
333///
334/// Each arm is a closure taking a perturbation seeded from the running checksum
335/// and returning a value folded back into it, so consecutive iterations carry a
336/// data dependence the optimizer cannot hoist across. Both closures must compute
337/// the SAME quantity — this measures speed and assumes agreement has already
338/// been established by a separate parity assertion.
339///
340/// `seed` fixes the order sequence, so a run is reproducible; vary it to check a
341/// verdict is not an artifact of one particular order sequence.
342///
343/// # Panics
344///
345/// If `reps` or `iterations` is zero, or if either arm's accumulated checksum is
346/// not finite — a non-finite checksum means the timed body degenerated (NaN
347/// short-circuits are often much faster) and the timing is meaningless.
348pub fn paired_interleaved<A, B>(
349    reps: usize,
350    iterations: usize,
351    seed: u64,
352    mut arm_a: A,
353    mut arm_b: B,
354) -> PairedTiming
355where
356    A: FnMut(f64) -> f64,
357    B: FnMut(f64) -> f64,
358{
359    assert!(reps > 0, "paired_interleaved needs at least one repetition");
360    assert!(
361        iterations > 0,
362        "paired_interleaved needs at least one iteration per repetition"
363    );
364
365    let mut a_ns = Vec::with_capacity(reps);
366    let mut b_ns = Vec::with_capacity(reps);
367    let mut ratios = Vec::with_capacity(reps);
368    let mut a_went_first = Vec::with_capacity(reps);
369    let mut rng = SplitMix64::new(seed);
370
371    for _ in 0..reps {
372        let a_first = rng.next_bool();
373        let (ta, tb) = if a_first {
374            let ta = time_arm(iterations, &mut arm_a);
375            let tb = time_arm(iterations, &mut arm_b);
376            (ta, tb)
377        } else {
378            let tb = time_arm(iterations, &mut arm_b);
379            let ta = time_arm(iterations, &mut arm_a);
380            (ta, tb)
381        };
382        ratios.push(tb / ta);
383        a_ns.push(ta);
384        b_ns.push(tb);
385        a_went_first.push(a_first);
386    }
387
388    PairedTiming {
389        a_ns,
390        b_ns,
391        ratios,
392        a_went_first,
393    }
394}
395
396/// Nanoseconds per iteration for one arm of one repetition.
397///
398/// The `checksum * 1e-18` perturbation is a feedback barrier, not a nudge: it
399/// makes iteration `n + 1` depend on the result of iteration `n`, which is what
400/// prevents the loop being hoisted or vectorised into something that is not the
401/// per-call cost being claimed. The scale is small enough that the arithmetic
402/// stays in the intended regime.
403fn time_arm<F: FnMut(f64) -> f64>(iterations: usize, arm: &mut F) -> f64 {
404    let mut checksum = 0.0_f64;
405    let started = Instant::now();
406    for _ in 0..iterations {
407        checksum += arm(black_box(checksum * 1e-18));
408    }
409    let elapsed = started.elapsed().as_secs_f64();
410    assert!(
411        black_box(checksum).is_finite(),
412        "timed arm accumulated a non-finite checksum, so the loop it timed is \
413         not the computation being compared"
414    );
415    elapsed * 1e9 / iterations as f64
416}
417
418fn median(values: &[f64]) -> f64 {
419    if values.is_empty() {
420        return f64::NAN;
421    }
422    let mut sorted = values.to_vec();
423    sorted.sort_by(f64::total_cmp);
424    let mid = sorted.len() / 2;
425    if sorted.len() % 2 == 1 {
426        sorted[mid]
427    } else {
428        0.5 * (sorted[mid - 1] + sorted[mid])
429    }
430}
431
432/// Linear-interpolated quantile of an already-sorted slice.
433fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
434    if sorted.is_empty() {
435        return f64::NAN;
436    }
437    if sorted.len() == 1 {
438        return sorted[0];
439    }
440    let pos = q.clamp(0.0, 1.0) * (sorted.len() - 1) as f64;
441    let lo = pos.floor() as usize;
442    let hi = pos.ceil() as usize;
443    let frac = pos - lo as f64;
444    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
445}
446
447/// SplitMix64 — a deterministic order sequence, so the interleave is randomised
448/// but a run is reproducible. Deliberately not a dependency: the harness must
449/// not be able to perturb the timing through an allocation or a dynamic call.
450struct SplitMix64(u64);
451
452impl SplitMix64 {
453    fn new(seed: u64) -> Self {
454        Self(seed)
455    }
456
457    fn next_u64(&mut self) -> u64 {
458        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
459        let mut z = self.0;
460        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
461        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
462        z ^ (z >> 31)
463    }
464
465    fn next_bool(&mut self) -> bool {
466        self.next_u64() & 1 == 1
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    /// Two arms doing identical work must land near ratio 1, and the harness
475    /// must say so through `wins_fraction` too: identical arms should win about
476    /// half the time, which is the signature a gate uses to recognise "no
477    /// measurable difference" rather than reading a point estimate of 1.02 as a
478    /// 2% win.
479    #[test]
480    fn identical_arms_report_no_winner() {
481        let work = |x: f64| {
482            let mut acc = x;
483            for i in 0..64 {
484                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
485            }
486            acc
487        };
488        let timing = paired_interleaved(21, 2_000, 0xA5A5_1234, work, work);
489        let median = timing.median_ratio();
490        assert!(
491            (median - 1.0).abs() < 0.35,
492            "identical arms should sit near ratio 1: {}",
493            timing.summary("a", "b")
494        );
495        let wins = timing.wins_fraction();
496        assert!(
497            (0.15..=0.85).contains(&wins),
498            "identical arms should win about half the repetitions: {}",
499            timing.summary("a", "b")
500        );
501    }
502
503    /// A genuinely faster arm must be detected with every repetition agreeing —
504    /// the property that separates a real margin from one inside the noise.
505    #[test]
506    fn a_large_real_difference_is_detected_unanimously() {
507        let fast = |x: f64| {
508            let mut acc = x;
509            for i in 0..16 {
510                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
511            }
512            acc
513        };
514        let slow = |x: f64| {
515            let mut acc = x;
516            for i in 0..256 {
517                acc = acc.mul_add(1.000_001, (i as f64) * 1e-9);
518            }
519            acc
520        };
521        let timing = paired_interleaved(15, 2_000, 0x5EED, fast, slow);
522        assert!(
523            timing.median_ratio() > 2.0,
524            "a 16x work difference must show as a large ratio: {}",
525            timing.summary("fast", "slow")
526        );
527        assert_eq!(
528            timing.wins_fraction(),
529            1.0,
530            "a large real difference must win EVERY repetition: {}",
531            timing.summary("fast", "slow")
532        );
533    }
534
535    /// The order must actually vary. A harness that believes it randomises but
536    /// does not is indistinguishable from the ones being replaced, and the
537    /// position-bias diagnostic would silently become `NaN`.
538    #[test]
539    fn both_orders_occur_and_position_bias_is_reportable() {
540        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
541        let timing = paired_interleaved(20, 500, 7, work, work);
542        let a_first = timing.a_went_first.iter().filter(|f| **f).count();
543        assert!(
544            a_first > 0 && a_first < timing.a_went_first.len(),
545            "both arm orders must occur across repetitions, got {a_first} of {}",
546            timing.a_went_first.len()
547        );
548        assert!(
549            timing.first_position_bias().is_finite(),
550            "position bias must be reportable once both orders occur"
551        );
552    }
553
554    /// `ratio_resolution` is what tells a caller whether its bar is assertable.
555    /// It must be finite and positive on a real measurement, or the gate has no
556    /// way to know it is asserting inside its own noise.
557    #[test]
558    fn resolution_is_reported_and_positive() {
559        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
560        let timing = paired_interleaved(15, 500, 99, work, work);
561        let resolution = timing.ratio_resolution();
562        assert!(
563            resolution.is_finite() && resolution > 0.0,
564            "resolution must be a usable number: {}",
565            timing.summary("a", "b")
566        );
567    }
568
569    /// The summary must carry the numbers that could overturn the verdict, not
570    /// just the verdict. A gate that prints only the ratio is how a 3% claim
571    /// with 6% resolution gets read as established.
572    #[test]
573    fn summary_carries_the_overturning_numbers() {
574        let work = |x: f64| x.mul_add(1.000_001, 1e-9);
575        let timing = paired_interleaved(9, 500, 3, work, work);
576        let line = timing.summary("production", "hand");
577        for field in [
578            "production=",
579            "hand=",
580            "median_ratio=",
581            "wins=",
582            "resolution=",
583            "position_bias=",
584            "reps=",
585        ] {
586            assert!(line.contains(field), "summary is missing {field}: {line}");
587        }
588    }
589
590    #[test]
591    #[should_panic(expected = "at least one repetition")]
592    fn zero_repetitions_is_refused_not_silently_empty() {
593        let work = |x: f64| x;
594        // Called as a bare statement. A discarding binding is banned in this
595        // workspace, and it would be the wrong shape regardless: this call is
596        // expected to panic, so there is no result to discard.
597        paired_interleaved(0, 10, 1, work, work);
598    }
599}