1use std::fmt::Write;
4
5use crate::db::{RunMeta, Summary};
6
7pub fn two_proportion_p(x1: u64, n1: u64, x2: u64, n2: u64) -> f64 {
9 if n1 == 0 || n2 == 0 {
10 return 1.0;
11 }
12 let (p1, p2) = (x1 as f64 / n1 as f64, x2 as f64 / n2 as f64);
13 let p = (x1 + x2) as f64 / (n1 + n2) as f64;
14 let se = (p * (1.0 - p) * (1.0 / n1 as f64 + 1.0 / n2 as f64)).sqrt();
15 if se == 0.0 {
16 return 1.0;
17 }
18 let z = ((p1 - p2) / se).abs();
19 2.0 * (1.0 - normal_cdf(z))
20}
21
22fn normal_cdf(z: f64) -> f64 {
24 let x = z / std::f64::consts::SQRT_2;
25 let t = 1.0 / (1.0 + 0.327_591_1 * x.abs());
26 let poly = t
27 * (0.254_829_592
28 + t * (-0.284_496_736
29 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
30 let erf = 1.0 - poly * (-x * x).exp();
31 let erf = if x >= 0.0 { erf } else { -erf };
32 0.5 * (1.0 + erf)
33}
34
35fn pct(x: u64, n: u64) -> String {
36 if n == 0 {
37 "-".into()
38 } else {
39 format!("{:.1}%", 100.0 * x as f64 / n as f64)
40 }
41}
42
43fn proportion_row(out: &mut String, label: &str, (x1, n1): (u64, u64), (x2, n2): (u64, u64)) {
44 if n1 == 0 && n2 == 0 {
45 return;
46 }
47 let delta = if n1 > 0 && n2 > 0 {
48 format!(
49 "{:+.1}pp",
50 100.0 * (x2 as f64 / n2 as f64 - x1 as f64 / n1 as f64)
51 )
52 } else {
53 "-".into()
54 };
55 let p = two_proportion_p(x1, n1, x2, n2);
56 let sig = if p < 0.05 {
57 format!("p={p:.3}")
58 } else {
59 format!("p={p:.2} n.s.")
60 };
61 writeln!(
62 out,
63 "{label:<16} {:>10} {:>10} {delta:>9} {sig}",
64 pct(x1, n1),
65 pct(x2, n2)
66 )
67 .unwrap();
68}
69
70pub fn render(
72 a: &RunMeta,
73 sa: &Summary,
74 b: &RunMeta,
75 sb: &Summary,
76 changed_outputs: usize,
77) -> String {
78 let mut o = String::new();
79 let who = |m: &RunMeta| {
80 format!(
81 "{} @ {}{}",
82 m.model,
83 m.runtime,
84 m.runtime_version
85 .as_ref()
86 .map_or(String::new(), |v| format!(" {v}"))
87 )
88 };
89 writeln!(o, "run {}: {} ({})", a.id, who(a), a.created_at).unwrap();
90 writeln!(o, "run {}: {} ({})", b.id, who(b), b.created_at).unwrap();
91 if a.prompts_blake3 != b.prompts_blake3 {
92 writeln!(o, "warning: the runs used different prompt files").unwrap();
93 }
94 writeln!(
95 o,
96 "{:<16} {:>10} {:>10} {:>9}",
97 "",
98 format!("run {}", a.id),
99 format!("run {}", b.id),
100 "delta"
101 )
102 .unwrap();
103 proportion_row(&mut o, "accuracy", (sa.ok, sa.n), (sb.ok, sb.n));
104 proportion_row(
105 &mut o,
106 "schema valid",
107 (sa.schema_ok, sa.schema_n),
108 (sb.schema_ok, sb.schema_n),
109 );
110 let num = |v: Option<f64>| v.map_or("-".into(), |x| format!("{x:.1}"));
111 let delta = match (sa.tokens_per_s_mean, sb.tokens_per_s_mean) {
112 (Some(x), Some(y)) => format!("{:+.1}", y - x),
113 _ => "-".into(),
114 };
115 writeln!(
116 o,
117 "{:<16} {:>10} {:>10} {delta:>9}",
118 "tokens/s (mean)",
119 num(sa.tokens_per_s_mean),
120 num(sb.tokens_per_s_mean)
121 )
122 .unwrap();
123 for (label, x, y) in [
124 ("latency p50", sa.latency_p50_ms, sb.latency_p50_ms),
125 ("latency p95", sa.latency_p95_ms, sb.latency_p95_ms),
126 ] {
127 writeln!(
128 o,
129 "{label:<16} {:>8}ms {:>8}ms {:>+7}ms",
130 x,
131 y,
132 y as i64 - x as i64
133 )
134 .unwrap();
135 }
136 writeln!(
137 o,
138 "{changed_outputs} of {} responses differ between the runs",
139 sa.n.min(sb.n)
140 )
141 .unwrap();
142 writeln!(
143 o,
144 "n.s. = not significant (two-proportion z-test, p >= 0.05); timings are not tested"
145 )
146 .unwrap();
147 o
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn z_test_matches_reference_values() {
156 let p = two_proportion_p(45, 50, 30, 50);
158 assert!((p - 0.000_532).abs() < 0.000_001, "{p}");
159 assert!(two_proportion_p(41, 50, 43, 50) > 0.5);
161 assert_eq!(two_proportion_p(10, 10, 10, 10), 1.0);
162 }
163}