1use std::collections::HashMap;
16
17use super::usage_meter::CohortUsage;
18
19pub const MIN_SAMPLES_PER_ARM: u64 = 30;
22
23const Z95: f64 = 1.959_964;
25
26const ESTIMATE_REL_UNCERTAINTY: f64 = 0.35;
30
31#[derive(Debug, Clone, PartialEq)]
33pub enum Savings {
34 Measured(Measured),
36 Pending {
38 control_n: u64,
39 treatment_n: u64,
40 needed: u64,
41 },
42 Estimated {
44 point_pct: f64,
45 low_pct: f64,
46 high_pct: f64,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct Measured {
53 pub control_avg: f64,
54 pub treatment_avg: f64,
55 pub tokens_saved_per_turn: f64,
56 pub reduction_pct: f64,
57 pub ci95_low_pct: f64,
58 pub ci95_high_pct: f64,
59 pub control_n: u64,
60 pub treatment_n: u64,
61}
62
63#[must_use]
65pub fn current() -> Savings {
66 from_cohorts(&super::usage_meter::persisted_cohorts())
67}
68
69#[must_use]
72pub fn to_json(s: &Savings) -> serde_json::Value {
73 match s {
74 Savings::Measured(m) => serde_json::json!({
75 "status": "measured",
76 "reduction_pct": round2(m.reduction_pct),
77 "ci95_low_pct": round2(m.ci95_low_pct),
78 "ci95_high_pct": round2(m.ci95_high_pct),
79 "control_avg_output": round2(m.control_avg),
80 "treatment_avg_output": round2(m.treatment_avg),
81 "tokens_saved_per_turn": round2(m.tokens_saved_per_turn),
82 "control_n": m.control_n,
83 "treatment_n": m.treatment_n,
84 }),
85 Savings::Pending {
86 control_n,
87 treatment_n,
88 needed,
89 } => serde_json::json!({
90 "status": "pending",
91 "control_n": control_n,
92 "treatment_n": treatment_n,
93 "needed_per_arm": needed,
94 }),
95 Savings::Estimated {
96 point_pct,
97 low_pct,
98 high_pct,
99 } => serde_json::json!({
100 "status": "estimated",
101 "point_pct": round2(*point_pct),
102 "low_pct": round2(*low_pct),
103 "high_pct": round2(*high_pct),
104 }),
105 }
106}
107
108fn round2(x: f64) -> f64 {
109 (x * 100.0).round() / 100.0
110}
111
112#[must_use]
114pub fn from_cohorts(cohorts: &HashMap<String, CohortUsage>) -> Savings {
115 let control = cohorts.get("control");
116 let treatment = cohorts.get("treatment");
117 let (control_n, treatment_n) = (
118 control.map_or(0, |c| c.requests),
119 treatment.map_or(0, |t| t.requests),
120 );
121
122 if control_n == 0 || treatment_n == 0 {
124 return estimated();
125 }
126 if control_n < MIN_SAMPLES_PER_ARM || treatment_n < MIN_SAMPLES_PER_ARM {
127 return Savings::Pending {
128 control_n,
129 treatment_n,
130 needed: MIN_SAMPLES_PER_ARM,
131 };
132 }
133 let (control, treatment) = (
135 control.expect("control cohort present when control_n > 0"),
136 treatment.expect("treatment cohort present when treatment_n > 0"),
137 );
138 match measured(control, treatment) {
139 Some(m) => Savings::Measured(m),
140 None => estimated(),
141 }
142}
143
144fn measured(control: &CohortUsage, treatment: &CohortUsage) -> Option<Measured> {
147 let control_avg = control.avg_output()?;
148 let treatment_avg = treatment.avg_output()?;
149 if control_avg <= 0.0 {
150 return None;
151 }
152 let var_c = control.variance_output()?;
153 let var_t = treatment.variance_output()?;
154 #[allow(clippy::cast_precision_loss)]
155 let (n_c, n_t) = (control.requests as f64, treatment.requests as f64);
156
157 let diff = control_avg - treatment_avg; let se = (var_c / n_c + var_t / n_t).sqrt();
159 let margin = Z95 * se;
160
161 Some(Measured {
162 control_avg,
163 treatment_avg,
164 tokens_saved_per_turn: diff,
165 reduction_pct: diff / control_avg * 100.0,
166 ci95_low_pct: (diff - margin) / control_avg * 100.0,
167 ci95_high_pct: (diff + margin) / control_avg * 100.0,
168 control_n: control.requests,
169 treatment_n: treatment.requests,
170 })
171}
172
173fn estimated() -> Savings {
175 let model = crate::core::stats::CostModel::default();
176 #[allow(clippy::cast_precision_loss)]
177 let verbose = model.avg_verbose_output_per_call as f64;
178 #[allow(clippy::cast_precision_loss)]
179 let concise = model.avg_concise_output_per_call as f64;
180 let point = if verbose > 0.0 {
181 (verbose - concise) / verbose * 100.0
182 } else {
183 0.0
184 };
185 let half = point * ESTIMATE_REL_UNCERTAINTY;
186 Savings::Estimated {
187 point_pct: point,
188 low_pct: (point - half).max(0.0),
189 high_pct: point + half,
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 fn cohort(requests: u64, output_tokens: u64, sum_sq_output: u64) -> CohortUsage {
198 CohortUsage {
199 requests,
200 input_tokens: 0,
201 output_tokens,
202 sum_sq_output,
203 }
204 }
205
206 fn from_samples(samples: &[u64]) -> CohortUsage {
208 let mut c = CohortUsage::default();
209 for &s in samples {
210 c.requests += 1;
211 c.output_tokens += s;
212 c.sum_sq_output += s * s;
213 }
214 c
215 }
216
217 #[test]
218 fn no_cohorts_falls_back_to_estimate() {
219 let s = from_cohorts(&HashMap::new());
220 match s {
221 Savings::Estimated {
222 point_pct,
223 low_pct,
224 high_pct,
225 } => {
226 assert!((point_pct - 33.333).abs() < 0.1, "point {point_pct}");
228 assert!(low_pct < point_pct && point_pct < high_pct);
229 assert!(low_pct >= 0.0);
230 }
231 other => panic!("expected Estimated, got {other:?}"),
232 }
233 }
234
235 #[test]
236 fn single_arm_only_is_estimate_not_measured() {
237 let mut m = HashMap::new();
238 m.insert("control".to_string(), cohort(100, 18_000, 3_240_000));
239 assert!(matches!(from_cohorts(&m), Savings::Estimated { .. }));
240 }
241
242 #[test]
243 fn small_paired_sample_is_pending() {
244 let mut m = HashMap::new();
245 m.insert("control".to_string(), from_samples(&[180, 190, 170]));
246 m.insert("treatment".to_string(), from_samples(&[120, 130, 110]));
247 match from_cohorts(&m) {
248 Savings::Pending {
249 control_n,
250 treatment_n,
251 needed,
252 } => {
253 assert_eq!(control_n, 3);
254 assert_eq!(treatment_n, 3);
255 assert_eq!(needed, MIN_SAMPLES_PER_ARM);
256 }
257 other => panic!("expected Pending, got {other:?}"),
258 }
259 }
260
261 #[test]
262 fn large_paired_sample_is_measured_with_ci() {
263 let control = from_samples(&[170, 180, 190, 185].repeat(10));
265 let treatment = from_samples(&[110, 120, 130, 125].repeat(10));
266 let mut m = HashMap::new();
267 m.insert("control".to_string(), control);
268 m.insert("treatment".to_string(), treatment);
269 match from_cohorts(&m) {
270 Savings::Measured(measured) => {
271 assert_eq!(measured.control_n, 40);
272 assert_eq!(measured.treatment_n, 40);
273 assert!(
274 (measured.reduction_pct - 33.0).abs() < 3.0,
275 "reduction {}",
276 measured.reduction_pct
277 );
278 assert!(
279 measured.ci95_low_pct < measured.reduction_pct
280 && measured.reduction_pct < measured.ci95_high_pct,
281 "CI must bracket the point estimate"
282 );
283 assert!(measured.tokens_saved_per_turn > 0.0);
284 }
285 other => panic!("expected Measured, got {other:?}"),
286 }
287 }
288
289 #[test]
290 fn zero_variance_constant_samples_yield_finite_ci() {
291 let control = from_samples(&[180; 40]);
293 let treatment = from_samples(&[120; 40]);
294 let mut m = HashMap::new();
295 m.insert("control".to_string(), control);
296 m.insert("treatment".to_string(), treatment);
297 match from_cohorts(&m) {
298 Savings::Measured(measured) => {
299 assert!((measured.reduction_pct - 33.333).abs() < 0.1);
300 assert!((measured.ci95_low_pct - measured.ci95_high_pct).abs() < 1e-6);
301 assert!(measured.reduction_pct.is_finite());
302 }
303 other => panic!("expected Measured, got {other:?}"),
304 }
305 }
306}