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}