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) = (control.unwrap(), treatment.unwrap());
135 match measured(control, treatment) {
136 Some(m) => Savings::Measured(m),
137 None => estimated(),
138 }
139}
140
141fn measured(control: &CohortUsage, treatment: &CohortUsage) -> Option<Measured> {
144 let control_avg = control.avg_output()?;
145 let treatment_avg = treatment.avg_output()?;
146 if control_avg <= 0.0 {
147 return None;
148 }
149 let var_c = control.variance_output()?;
150 let var_t = treatment.variance_output()?;
151 #[allow(clippy::cast_precision_loss)]
152 let (n_c, n_t) = (control.requests as f64, treatment.requests as f64);
153
154 let diff = control_avg - treatment_avg; let se = (var_c / n_c + var_t / n_t).sqrt();
156 let margin = Z95 * se;
157
158 Some(Measured {
159 control_avg,
160 treatment_avg,
161 tokens_saved_per_turn: diff,
162 reduction_pct: diff / control_avg * 100.0,
163 ci95_low_pct: (diff - margin) / control_avg * 100.0,
164 ci95_high_pct: (diff + margin) / control_avg * 100.0,
165 control_n: control.requests,
166 treatment_n: treatment.requests,
167 })
168}
169
170fn estimated() -> Savings {
172 let model = crate::core::stats::CostModel::default();
173 #[allow(clippy::cast_precision_loss)]
174 let verbose = model.avg_verbose_output_per_call as f64;
175 #[allow(clippy::cast_precision_loss)]
176 let concise = model.avg_concise_output_per_call as f64;
177 let point = if verbose > 0.0 {
178 (verbose - concise) / verbose * 100.0
179 } else {
180 0.0
181 };
182 let half = point * ESTIMATE_REL_UNCERTAINTY;
183 Savings::Estimated {
184 point_pct: point,
185 low_pct: (point - half).max(0.0),
186 high_pct: point + half,
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn cohort(requests: u64, output_tokens: u64, sum_sq_output: u64) -> CohortUsage {
195 CohortUsage {
196 requests,
197 input_tokens: 0,
198 output_tokens,
199 sum_sq_output,
200 }
201 }
202
203 fn from_samples(samples: &[u64]) -> CohortUsage {
205 let mut c = CohortUsage::default();
206 for &s in samples {
207 c.requests += 1;
208 c.output_tokens += s;
209 c.sum_sq_output += s * s;
210 }
211 c
212 }
213
214 #[test]
215 fn no_cohorts_falls_back_to_estimate() {
216 let s = from_cohorts(&HashMap::new());
217 match s {
218 Savings::Estimated {
219 point_pct,
220 low_pct,
221 high_pct,
222 } => {
223 assert!((point_pct - 33.333).abs() < 0.1, "point {point_pct}");
225 assert!(low_pct < point_pct && point_pct < high_pct);
226 assert!(low_pct >= 0.0);
227 }
228 other => panic!("expected Estimated, got {other:?}"),
229 }
230 }
231
232 #[test]
233 fn single_arm_only_is_estimate_not_measured() {
234 let mut m = HashMap::new();
235 m.insert("control".to_string(), cohort(100, 18_000, 3_240_000));
236 assert!(matches!(from_cohorts(&m), Savings::Estimated { .. }));
237 }
238
239 #[test]
240 fn small_paired_sample_is_pending() {
241 let mut m = HashMap::new();
242 m.insert("control".to_string(), from_samples(&[180, 190, 170]));
243 m.insert("treatment".to_string(), from_samples(&[120, 130, 110]));
244 match from_cohorts(&m) {
245 Savings::Pending {
246 control_n,
247 treatment_n,
248 needed,
249 } => {
250 assert_eq!(control_n, 3);
251 assert_eq!(treatment_n, 3);
252 assert_eq!(needed, MIN_SAMPLES_PER_ARM);
253 }
254 other => panic!("expected Pending, got {other:?}"),
255 }
256 }
257
258 #[test]
259 fn large_paired_sample_is_measured_with_ci() {
260 let control = from_samples(&[170, 180, 190, 185].repeat(10));
262 let treatment = from_samples(&[110, 120, 130, 125].repeat(10));
263 let mut m = HashMap::new();
264 m.insert("control".to_string(), control);
265 m.insert("treatment".to_string(), treatment);
266 match from_cohorts(&m) {
267 Savings::Measured(measured) => {
268 assert_eq!(measured.control_n, 40);
269 assert_eq!(measured.treatment_n, 40);
270 assert!(
271 (measured.reduction_pct - 33.0).abs() < 3.0,
272 "reduction {}",
273 measured.reduction_pct
274 );
275 assert!(
276 measured.ci95_low_pct < measured.reduction_pct
277 && measured.reduction_pct < measured.ci95_high_pct,
278 "CI must bracket the point estimate"
279 );
280 assert!(measured.tokens_saved_per_turn > 0.0);
281 }
282 other => panic!("expected Measured, got {other:?}"),
283 }
284 }
285
286 #[test]
287 fn zero_variance_constant_samples_yield_finite_ci() {
288 let control = from_samples(&[180; 40]);
290 let treatment = from_samples(&[120; 40]);
291 let mut m = HashMap::new();
292 m.insert("control".to_string(), control);
293 m.insert("treatment".to_string(), treatment);
294 match from_cohorts(&m) {
295 Savings::Measured(measured) => {
296 assert!((measured.reduction_pct - 33.333).abs() < 0.1);
297 assert!((measured.ci95_low_pct - measured.ci95_high_pct).abs() < 1e-6);
298 assert!(measured.reduction_pct.is_finite());
299 }
300 other => panic!("expected Measured, got {other:?}"),
301 }
302 }
303}