Skip to main content

jugar_probar/perf_gate/
replicate.rs

1//! PP-LLAMA-001 v3.0 §4.3 — the **replicate-unit** estimator.
2//!
3//! Window statistics (`agg`, `prefill`, `vram_peak`) are one number per band
4//! run, so there is nothing inside a band to resample: the unit of variation is
5//! the *replicate*. §4.3 decides them with
6//!
7//! > the mean of the per-replicate `ln(x_apr / x_llama)`, bounded below by a
8//! > one-sided 95% Student-t bound with `df = n − 1`, exponentiated.
9//!
10//! Three things about that are load-bearing and each is a separate test here.
11//!
12//! **Logs, not raw ratios.** A ratio's sampling distribution is skewed and its
13//! arithmetic mean is not the ratio of the means. `ln` makes the paired
14//! comparison additive, and exponentiating the bound returns a bound on the
15//! ratio.
16//!
17//! **One-sided, not two-sided.** P-5 asks "is the lower bound at or above
18//! `1 − δ`", which is a one-sided question. The two-tailed table
19//! (`llm/benchmark.rs::t_critical_95`, `df = 4 → 2.776`) is the wrong quantile
20//! for it — at `df = 4` the one-sided value is `2.132` — and it is behind
21//! `#[cfg(feature = "llm")]`, so it is invisible to CI's default-feature run.
22//! It is deliberately not reused.
23//!
24//! **`n ≥ 5`, and interleaved.** §4.3: "`n = 3` sizes an effect and bounds no
25//! variance: no σ-dependent status changes at `n < 5`." And the replicates must
26//! alternate A,B,A,B,… — thermal state, JIT/graph-capture warm state and free
27//! VRAM all drift across a sweep, and alternation is the only design that
28//! cancels the drift. Both are refusals here, not warnings:
29//! [`log_ratio_lcb`] returns `None` and the caller reports the point estimate
30//! with `lcb95: null`.
31
32use serde::{Deserialize, Serialize};
33
34use super::join::{Ratio, RatioMethod};
35
36/// §4.3 — the replicate floor. Below it there is no verdict.
37pub const MIN_REPLICATES: usize = 5;
38
39/// Which arm ran **first** in one replicate. Recorded per replicate so
40/// interleaving is a property of the data rather than of a claim about it.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ArmOrder {
44    /// Subject first, comparator second.
45    SubjectFirst,
46    /// Comparator first, subject second.
47    ComparatorFirst,
48}
49
50/// One interleaved replicate: the same window statistic from both lanes, plus
51/// the order they ran in.
52#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct ReplicatePair {
55    /// The subject lane's value.
56    pub subject: f64,
57    /// The comparator lane's value.
58    pub comparator: f64,
59    /// Which lane ran first in this replicate.
60    pub order: ArmOrder,
61}
62
63/// One-sided 95% Student-t critical value, `df` degrees of freedom.
64///
65/// The published table for `df = 1..=30`; `1.645` (the normal quantile) beyond,
66/// which is where the t distribution has converged to within 0.01. `df = 0` has
67/// no bound and returns infinity, so a caller that reaches it produces
68/// `lcb95 = −∞` rather than a plausible number.
69#[must_use]
70pub fn t_lower_one_sided_95(df: usize) -> f64 {
71    const TABLE: [f64; 30] = [
72        6.314, 2.920, 2.353, 2.132, 2.015, 1.943, 1.895, 1.860, 1.833, 1.812, 1.796, 1.782, 1.771,
73        1.761, 1.753, 1.746, 1.740, 1.734, 1.729, 1.725, 1.721, 1.717, 1.714, 1.711, 1.708, 1.706,
74        1.703, 1.701, 1.699, 1.697,
75    ];
76    match df {
77        0 => f64::INFINITY,
78        d if d <= TABLE.len() => TABLE[d - 1],
79        _ => 1.645,
80    }
81}
82
83/// §4.3 — the exponentiated one-sided 95% lower bound on the mean log-ratio.
84///
85/// Returns `None` — no verdict — when
86///
87/// - fewer than [`MIN_REPLICATES`] pairs were supplied, or
88/// - the pairs are not strictly alternating (`order` must flip every replicate),
89/// - or any pair carries a non-positive value, where `ln` is undefined.
90///
91/// A caller that gets `None` reports [`log_ratio_point`] instead, which carries
92/// `lcb95: null` and the same `n`, so the ratio is still on the receipt and
93/// still cannot be used as a verdict.
94#[must_use]
95pub fn log_ratio_lcb(pairs: &[ReplicatePair]) -> Option<Ratio> {
96    if pairs.len() < MIN_REPLICATES || !is_strictly_alternating(pairs) {
97        return None;
98    }
99    let logs = log_ratios(pairs)?;
100    let n = logs.len();
101    let mean = logs.iter().sum::<f64>() / n as f64;
102    let var = logs.iter().map(|l| (l - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
103    let se = (var / n as f64).sqrt();
104    let t = t_lower_one_sided_95(n - 1);
105    Some(Ratio {
106        point: mean.exp(),
107        lcb95: Some(t.mul_add(-se, mean).exp()),
108        method: RatioMethod::ReplicateTLower,
109        n,
110    })
111}
112
113/// The point estimate alone — `exp(mean(ln(subject/comparator)))` — for a
114/// design that cannot support a bound.
115///
116/// `lcb95` is `None`, never the point estimate and never `0.0`: §4.3's "`n < 5`
117/// → reporting only" has to be visible in the receipt, and a bound equal to the
118/// point estimate would read as impossible precision.
119#[must_use]
120pub fn log_ratio_point(pairs: &[ReplicatePair]) -> Option<Ratio> {
121    let logs = log_ratios(pairs)?;
122    let n = logs.len();
123    let mean = logs.iter().sum::<f64>() / n as f64;
124    Some(Ratio::reporting_only(
125        mean.exp(),
126        RatioMethod::ReplicateTLower,
127        n,
128    ))
129}
130
131/// [`log_ratio_lcb`] when the design supports it, [`log_ratio_point`] otherwise.
132/// The receipt always carries a ratio; only the bound is conditional.
133#[must_use]
134pub fn log_ratio_bound_or_point(pairs: &[ReplicatePair]) -> Option<Ratio> {
135    log_ratio_lcb(pairs).or_else(|| log_ratio_point(pairs))
136}
137
138fn log_ratios(pairs: &[ReplicatePair]) -> Option<Vec<f64>> {
139    if pairs.len() < 2 {
140        return None;
141    }
142    pairs
143        .iter()
144        .map(|p| {
145            if p.subject > 0.0 && p.comparator > 0.0 {
146                Some((p.subject / p.comparator).ln())
147            } else {
148                None
149            }
150        })
151        .collect()
152}
153
154/// §4.3 — A,B,A,B,…: the arm that goes first must flip every replicate.
155fn is_strictly_alternating(pairs: &[ReplicatePair]) -> bool {
156    pairs.windows(2).all(|w| w[0].order != w[1].order)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn alternating(values: &[(f64, f64)]) -> Vec<ReplicatePair> {
164        values
165            .iter()
166            .enumerate()
167            .map(|(i, &(subject, comparator))| ReplicatePair {
168                subject,
169                comparator,
170                order: if i % 2 == 0 {
171                    ArmOrder::SubjectFirst
172                } else {
173                    ArmOrder::ComparatorFirst
174                },
175            })
176            .collect()
177    }
178
179    /// The published one-sided 95% t values. If this reds, every bound moved.
180    #[test]
181    fn one_sided_t_table_matches_published_values() {
182        for (df, want) in [
183            (1_usize, 6.314_f64),
184            (2, 2.920),
185            (3, 2.353),
186            (4, 2.132),
187            (5, 2.015),
188            (6, 1.943),
189            (7, 1.895),
190            (8, 1.860),
191            (9, 1.833),
192            (10, 1.812),
193            (11, 1.796),
194            (12, 1.782),
195            (13, 1.771),
196            (14, 1.761),
197            (15, 1.753),
198            (16, 1.746),
199            (17, 1.740),
200            (18, 1.734),
201            (19, 1.729),
202            (20, 1.725),
203            (21, 1.721),
204            (22, 1.717),
205            (23, 1.714),
206            (24, 1.711),
207            (25, 1.708),
208            (26, 1.706),
209            (27, 1.703),
210            (28, 1.701),
211            (29, 1.699),
212            (30, 1.697),
213        ] {
214            assert_eq!(t_lower_one_sided_95(df), want, "df={df}");
215        }
216        assert_eq!(t_lower_one_sided_95(31), 1.645, "beyond 30, the normal");
217        assert_eq!(t_lower_one_sided_95(1_000), 1.645);
218        assert!(
219            t_lower_one_sided_95(0).is_infinite(),
220            "df=0 supports no bound"
221        );
222    }
223
224    /// It is the ONE-SIDED table. The two-tailed 95% value at df=4 is 2.776;
225    /// using it would widen every bound by 30% and silently pass regressions.
226    #[test]
227    fn the_table_is_one_sided_not_two_tailed() {
228        assert_eq!(t_lower_one_sided_95(4), 2.132);
229        assert_ne!(t_lower_one_sided_95(4), 2.776);
230    }
231
232    /// §4.3 — `n = 3` bounds no variance, so there is no bound.
233    #[test]
234    fn fewer_than_five_replicates_give_no_bound() {
235        let three = alternating(&[(100.0, 90.0), (101.0, 91.0), (99.0, 89.0)]);
236        assert!(log_ratio_lcb(&three).is_none());
237        assert_eq!(MIN_REPLICATES, 5);
238
239        // …but the point estimate is still reported, with a null bound.
240        let reporting = log_ratio_point(&three).expect("point estimate exists");
241        assert!(reporting.lcb95.is_none());
242        assert_eq!(reporting.n, 3);
243        assert!(!reporting.passes(0.0), "no bound is not a pass");
244
245        // And five is enough.
246        let five = alternating(&[
247            (100.0, 90.0),
248            (101.0, 91.0),
249            (99.0, 89.0),
250            (100.5, 90.5),
251            (100.2, 90.1),
252        ]);
253        assert!(log_ratio_lcb(&five).is_some());
254    }
255
256    /// §4.3 — replicates that did not alternate did not cancel the drift, so
257    /// they do not carry a bound however many of them there are.
258    #[test]
259    fn non_alternating_order_is_refused() {
260        let mut pairs = alternating(&[
261            (100.0, 90.0),
262            (101.0, 91.0),
263            (99.0, 89.0),
264            (100.5, 90.5),
265            (100.2, 90.1),
266        ]);
267        assert!(log_ratio_lcb(&pairs).is_some(), "control: alternating");
268        pairs[3].order = pairs[2].order;
269        assert!(
270            log_ratio_lcb(&pairs).is_none(),
271            "two consecutive replicates led with the same arm"
272        );
273        // The point estimate survives; only the verdict is withdrawn.
274        assert!(log_ratio_point(&pairs).is_some());
275    }
276
277    /// The bound is a bound on the RATIO: the estimate is formed in log space
278    /// and exponentiated, so `point` is the geometric mean of the per-replicate
279    /// ratios and `lcb95` sits below it.
280    #[test]
281    fn log_ratio_bound_is_exponentiated() {
282        // Every replicate is exactly 1.10, so the geometric mean is 1.10 and the
283        // spread is zero: the bound coincides with the point.
284        let flat = alternating(&[
285            (110.0, 100.0),
286            (220.0, 200.0),
287            (55.0, 50.0),
288            (11.0, 10.0),
289            (1100.0, 1000.0),
290        ]);
291        let r = log_ratio_lcb(&flat).expect("n = 5, alternating");
292        assert!((r.point - 1.10).abs() < 1e-12, "{r:?}");
293        assert!(
294            (r.lcb95.expect("bounded") - 1.10).abs() < 1e-12,
295            "zero variance leaves the bound at the point: {r:?}"
296        );
297        assert_eq!(r.method, RatioMethod::ReplicateTLower);
298        assert_eq!(r.n, 5);
299
300        // An arithmetic mean of the ratios would give a different centre for a
301        // skewed set; the geometric mean of 0.5 and 2.0 is 1.0, not 1.25.
302        let skewed = alternating(&[
303            (50.0, 100.0),
304            (200.0, 100.0),
305            (50.0, 100.0),
306            (200.0, 100.0),
307            (100.0, 100.0),
308        ]);
309        let g = log_ratio_lcb(&skewed).expect("n = 5");
310        assert!((g.point - 1.0).abs() < 1e-12, "geometric mean: {g:?}");
311        assert!(g.lcb95.expect("bounded") < g.point, "{g:?}");
312    }
313
314    /// The bound must MOVE with dispersion, or `t · se` is decoration.
315    #[test]
316    fn more_dispersion_lowers_the_bound() {
317        let tight = alternating(&[
318            (110.0, 100.0),
319            (109.0, 100.0),
320            (111.0, 100.0),
321            (110.5, 100.0),
322            (109.5, 100.0),
323        ]);
324        let loose = alternating(&[
325            (60.0, 100.0),
326            (160.0, 100.0),
327            (70.0, 100.0),
328            (150.0, 100.0),
329            (110.0, 100.0),
330        ]);
331        let a = log_ratio_lcb(&tight).expect("n = 5");
332        let b = log_ratio_lcb(&loose).expect("n = 5");
333        assert!(
334            b.lcb95.expect("bounded") < a.lcb95.expect("bounded"),
335            "dispersed {b:?} must bound lower than tight {a:?}"
336        );
337    }
338
339    /// One replicate is not a paired design: there is nothing to average and
340    /// nothing to bound, so there is no ratio at all rather than a ratio of
341    /// impossible precision.
342    #[test]
343    fn a_single_replicate_has_no_log_ratio() {
344        let one = alternating(&[(110.0, 100.0)]);
345        assert!(log_ratio_point(&one).is_none());
346        assert!(log_ratio_lcb(&one).is_none());
347        assert!(log_ratio_bound_or_point(&one).is_none());
348        assert!(log_ratio_point(&[]).is_none());
349        // Two is enough for a point estimate, still not for a bound.
350        let two = alternating(&[(110.0, 100.0), (90.0, 100.0)]);
351        let r = log_ratio_point(&two).expect("two pairs give a point");
352        assert_eq!(r.n, 2);
353        assert!(r.lcb95.is_none());
354    }
355
356    /// A non-positive lane value has no logarithm, and a zero-throughput lane is
357    /// not a ratio of any kind.
358    #[test]
359    fn a_zero_lane_has_no_log_ratio() {
360        let zeroed = alternating(&[
361            (110.0, 100.0),
362            (0.0, 100.0),
363            (111.0, 100.0),
364            (110.5, 100.0),
365            (109.5, 100.0),
366        ]);
367        assert!(log_ratio_lcb(&zeroed).is_none());
368        assert!(log_ratio_point(&zeroed).is_none());
369    }
370
371    /// The convenience wrapper reports when it cannot bound, and bounds when it
372    /// can — so a caller never has to choose between "no ratio" and "a ratio
373    /// that pretends to a verdict".
374    #[test]
375    fn the_wrapper_falls_back_to_reporting_only() {
376        let three = alternating(&[(100.0, 90.0), (101.0, 91.0), (99.0, 89.0)]);
377        let r = log_ratio_bound_or_point(&three).expect("point estimate");
378        assert!(r.lcb95.is_none());
379        let five = alternating(&[
380            (100.0, 90.0),
381            (101.0, 91.0),
382            (99.0, 89.0),
383            (100.5, 90.5),
384            (100.2, 90.1),
385        ]);
386        assert!(log_ratio_bound_or_point(&five)
387            .expect("bounded")
388            .lcb95
389            .is_some());
390    }
391}