1use std::collections::HashMap;
17
18use super::usage_meter::CohortUsage;
19
20pub const MIN_SAMPLES_PER_ARM: u64 = 30;
23
24const Z95: f64 = 1.959_964;
26
27const ESTIMATE_REL_UNCERTAINTY: f64 = 0.35;
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum Savings {
35 Measured(Measured),
37 Pending {
39 control_n: u64,
40 treatment_n: u64,
41 needed: u64,
42 },
43 Estimated {
45 point_pct: f64,
46 low_pct: f64,
47 high_pct: f64,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct Measured {
54 pub control_avg: f64,
55 pub treatment_avg: f64,
56 pub tokens_saved_per_turn: f64,
57 pub reduction_pct: f64,
58 pub ci95_low_pct: f64,
59 pub ci95_high_pct: f64,
60 pub control_n: u64,
61 pub treatment_n: u64,
62}
63
64#[must_use]
66pub fn current() -> Savings {
67 from_cohorts(&super::usage_meter::persisted_cohorts())
68}
69
70#[must_use]
73pub fn to_json(s: &Savings) -> serde_json::Value {
74 match s {
75 Savings::Measured(m) => serde_json::json!({
76 "status": "measured",
77 "reduction_pct": round2(m.reduction_pct),
78 "ci95_low_pct": round2(m.ci95_low_pct),
79 "ci95_high_pct": round2(m.ci95_high_pct),
80 "control_avg_output": round2(m.control_avg),
81 "treatment_avg_output": round2(m.treatment_avg),
82 "tokens_saved_per_turn": round2(m.tokens_saved_per_turn),
83 "control_n": m.control_n,
84 "treatment_n": m.treatment_n,
85 }),
86 Savings::Pending {
87 control_n,
88 treatment_n,
89 needed,
90 } => serde_json::json!({
91 "status": "pending",
92 "control_n": control_n,
93 "treatment_n": treatment_n,
94 "needed_per_arm": needed,
95 }),
96 Savings::Estimated {
97 point_pct,
98 low_pct,
99 high_pct,
100 } => serde_json::json!({
101 "status": "estimated",
102 "point_pct": round2(*point_pct),
103 "low_pct": round2(*low_pct),
104 "high_pct": round2(*high_pct),
105 }),
106 }
107}
108
109fn round2(x: f64) -> f64 {
110 (x * 100.0).round() / 100.0
111}
112
113#[must_use]
115pub fn from_cohorts(cohorts: &HashMap<String, CohortUsage>) -> Savings {
116 let control = cohorts.get("control");
117 let treatment = cohorts.get("treatment");
118 let (control_n, treatment_n) = (
119 control.map_or(0, |c| c.requests),
120 treatment.map_or(0, |t| t.requests),
121 );
122
123 if control_n == 0 || treatment_n == 0 {
125 return estimated();
126 }
127 if control_n < MIN_SAMPLES_PER_ARM || treatment_n < MIN_SAMPLES_PER_ARM {
128 return Savings::Pending {
129 control_n,
130 treatment_n,
131 needed: MIN_SAMPLES_PER_ARM,
132 };
133 }
134 let (control, treatment) = (
136 control.expect("control cohort present when control_n > 0"),
137 treatment.expect("treatment cohort present when treatment_n > 0"),
138 );
139 match measured(control, treatment) {
140 Some(m) => Savings::Measured(m),
141 None => estimated(),
142 }
143}
144
145fn measured(control: &CohortUsage, treatment: &CohortUsage) -> Option<Measured> {
150 if control.requests < MIN_SAMPLES_PER_ARM || treatment.requests < MIN_SAMPLES_PER_ARM {
151 return None;
152 }
153 welch_reduction(
154 Sample::new(
155 control.output_tokens,
156 control.sum_sq_output,
157 control.requests,
158 ),
159 Sample::new(
160 treatment.output_tokens,
161 treatment.sum_sq_output,
162 treatment.requests,
163 ),
164 )
165}
166
167#[derive(Debug, Clone, Copy)]
169pub(crate) struct Sample {
170 sum: u64,
171 sum_sq: u64,
172 n: u64,
173}
174
175impl Sample {
176 pub(crate) fn new(sum: u64, sum_sq: u64, n: u64) -> Self {
177 Self { sum, sum_sq, n }
178 }
179
180 #[allow(clippy::cast_precision_loss)]
181 fn mean(self) -> Option<f64> {
182 (self.n > 0).then(|| self.sum as f64 / self.n as f64)
183 }
184
185 #[allow(clippy::cast_precision_loss)]
189 fn variance(self) -> Option<f64> {
190 if self.n < 2 {
191 return None;
192 }
193 let sum = u128::from(self.sum);
194 if u128::from(self.sum_sq) * u128::from(self.n) < sum * sum {
195 return None;
196 }
197 let (n, sum, sum_sq) = (self.n as f64, self.sum as f64, self.sum_sq as f64);
198 Some(((sum_sq - sum * sum / n) / (n - 1.0)).max(0.0))
199 }
200}
201
202pub(crate) fn welch_reduction(control: Sample, treatment: Sample) -> Option<Measured> {
206 let control_avg = control.mean()?;
207 let treatment_avg = treatment.mean()?;
208 if control_avg <= 0.0 {
209 return None;
210 }
211 let var_c = control.variance()?;
212 let var_t = treatment.variance()?;
213 #[allow(clippy::cast_precision_loss)]
214 let (n_c, n_t) = (control.n as f64, treatment.n as f64);
215
216 let diff = control_avg - treatment_avg; let se = (var_c / n_c + var_t / n_t).sqrt();
218 if !(se.is_finite() && se > 0.0) {
221 return None;
222 }
223 let margin = Z95 * se;
224
225 Some(Measured {
226 control_avg,
227 treatment_avg,
228 tokens_saved_per_turn: diff,
229 reduction_pct: diff / control_avg * 100.0,
230 ci95_low_pct: (diff - margin) / control_avg * 100.0,
231 ci95_high_pct: (diff + margin) / control_avg * 100.0,
232 control_n: control.n,
233 treatment_n: treatment.n,
234 })
235}
236
237fn estimated() -> Savings {
239 let model = lean_ctx::core::stats::CostModel::default();
240 #[allow(clippy::cast_precision_loss)]
241 let verbose = model.avg_verbose_output_per_call as f64;
242 #[allow(clippy::cast_precision_loss)]
243 let concise = model.avg_concise_output_per_call as f64;
244 let point = if verbose > 0.0 {
245 (verbose - concise) / verbose * 100.0
246 } else {
247 0.0
248 };
249 let half = point * ESTIMATE_REL_UNCERTAINTY;
250 Savings::Estimated {
251 point_pct: point,
252 low_pct: (point - half).max(0.0),
253 high_pct: point + half,
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn cohort(requests: u64, output_tokens: u64, sum_sq_output: u64) -> CohortUsage {
262 CohortUsage {
263 requests,
264 input_tokens: 0,
265 output_tokens,
266 sum_sq_output,
267 ..CohortUsage::default()
268 }
269 }
270
271 fn from_samples(samples: &[u64]) -> CohortUsage {
273 let mut c = CohortUsage::default();
274 for &s in samples {
275 c.requests += 1;
276 c.output_tokens += s;
277 c.sum_sq_output += s * s;
278 }
279 c
280 }
281
282 #[test]
283 fn no_cohorts_falls_back_to_estimate() {
284 let s = from_cohorts(&HashMap::new());
285 match s {
286 Savings::Estimated {
287 point_pct,
288 low_pct,
289 high_pct,
290 } => {
291 assert!((point_pct - 33.333).abs() < 0.1, "point {point_pct}");
293 assert!(low_pct < point_pct && point_pct < high_pct);
294 assert!(low_pct >= 0.0);
295 }
296 other => panic!("expected Estimated, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn single_arm_only_is_estimate_not_measured() {
302 let mut m = HashMap::new();
303 m.insert("control".to_string(), cohort(100, 18_000, 3_240_000));
304 assert!(matches!(from_cohorts(&m), Savings::Estimated { .. }));
305 }
306
307 #[test]
308 fn small_paired_sample_is_pending() {
309 let mut m = HashMap::new();
310 m.insert("control".to_string(), from_samples(&[180, 190, 170]));
311 m.insert("treatment".to_string(), from_samples(&[120, 130, 110]));
312 match from_cohorts(&m) {
313 Savings::Pending {
314 control_n,
315 treatment_n,
316 needed,
317 } => {
318 assert_eq!(control_n, 3);
319 assert_eq!(treatment_n, 3);
320 assert_eq!(needed, MIN_SAMPLES_PER_ARM);
321 }
322 other => panic!("expected Pending, got {other:?}"),
323 }
324 }
325
326 #[test]
327 fn large_paired_sample_is_measured_with_ci() {
328 let control = from_samples(&[170, 180, 190, 185].repeat(10));
330 let treatment = from_samples(&[110, 120, 130, 125].repeat(10));
331 let mut m = HashMap::new();
332 m.insert("control".to_string(), control);
333 m.insert("treatment".to_string(), treatment);
334 match from_cohorts(&m) {
335 Savings::Measured(measured) => {
336 assert_eq!(measured.control_n, 40);
337 assert_eq!(measured.treatment_n, 40);
338 assert!(
339 (measured.reduction_pct - 33.0).abs() < 3.0,
340 "reduction {}",
341 measured.reduction_pct
342 );
343 assert!(
344 measured.ci95_low_pct < measured.reduction_pct
345 && measured.reduction_pct < measured.ci95_high_pct,
346 "CI must bracket the point estimate"
347 );
348 assert!(measured.tokens_saved_per_turn > 0.0);
349 }
350 other => panic!("expected Measured, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn zero_variance_is_not_reported_as_measured() {
356 let control = from_samples(&[180; 40]);
359 let treatment = from_samples(&[120; 40]);
360 let mut m = HashMap::new();
361 m.insert("control".to_string(), control);
362 m.insert("treatment".to_string(), treatment);
363 assert!(
364 !matches!(from_cohorts(&m), Savings::Measured(_)),
365 "zero variance must not be reported as measured"
366 );
367 }
368
369 #[test]
370 fn lost_sums_of_squares_are_not_reported_as_measured() {
371 let mut control = from_samples(&[170, 180, 190, 185].repeat(10));
374 let mut treatment = from_samples(&[110, 120, 130, 125].repeat(10));
375 control.sum_sq_output = 0;
376 treatment.sum_sq_output = 0;
377 let mut m = HashMap::new();
378 m.insert("control".to_string(), control);
379 m.insert("treatment".to_string(), treatment);
380 assert!(!matches!(from_cohorts(&m), Savings::Measured(_)));
381 }
382
383 #[test]
384 fn measured_requires_minimum_samples_on_its_own() {
385 let control = from_samples(&[170, 180, 190]);
386 let treatment = from_samples(&[110, 120, 130]);
387 assert!(measured(&control, &treatment).is_none());
388 }
389}