Skip to main content

lean_ctx/proxy/
output_savings.rs

1//! Output-token savings reporting (#895 Track B).
2//!
3//! lean-ctx shapes *output* tokens (cache-safe effort control #834 and the
4//! optional verbosity steer). To report that honestly we distinguish two cases:
5//!
6//! * **Measured** — when a holdout ([`crate::core::config::ProxyConfig::output_holdout_fraction`])
7//!   has produced enough paired turns, the reduction is
8//!   `(control_avg − treatment_avg) / control_avg`, with a 95 % confidence
9//!   interval from a two-sample (Welch) standard error on the difference of mean
10//!   output tokens. This is a real A/B result, not a guess.
11//! * **Estimated** — with no holdout (or not enough data yet) we fall back to the
12//!   shared model-based heuristic and present it as a *band*, never a hard
13//!   number, clearly labelled so it is never mistaken for a measurement.
14
15use std::collections::HashMap;
16
17use super::usage_meter::CohortUsage;
18
19/// Minimum turns *per arm* before a measured reduction is reported. Below this
20/// the interval is too wide to be informative, so we report [`Savings::Pending`].
21pub const MIN_SAMPLES_PER_ARM: u64 = 30;
22
23/// Standard-normal 97.5th percentile (two-sided 95 % CI multiplier).
24const Z95: f64 = 1.959_964;
25
26/// Relative half-width applied to the model-based point estimate to express its
27/// inherent uncertainty as a band. This is an *estimate* parameter, not a
28/// measurement — enabling `output_holdout` replaces it with a real CI.
29const ESTIMATE_REL_UNCERTAINTY: f64 = 0.35;
30
31/// Outcome of an output-savings query, ready for the CLI / dashboard to render.
32#[derive(Debug, Clone, PartialEq)]
33pub enum Savings {
34    /// Real A/B result from the holdout.
35    Measured(Measured),
36    /// Holdout running but not enough paired turns yet.
37    Pending {
38        control_n: u64,
39        treatment_n: u64,
40        needed: u64,
41    },
42    /// No holdout: model-based estimate, presented as a band.
43    Estimated {
44        point_pct: f64,
45        low_pct: f64,
46        high_pct: f64,
47    },
48}
49
50/// A measured output-token reduction with its 95 % confidence interval.
51#[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/// Computes the output-savings outcome from the persisted cohort totals.
64#[must_use]
65pub fn current() -> Savings {
66    from_cohorts(&super::usage_meter::persisted_cohorts())
67}
68
69/// Stable JSON shape for a [`Savings`], shared by the `lean-ctx output-savings`
70/// CLI and the dashboard `/api/roi` route so both speak one schema.
71#[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/// Pure core: decide measured / pending / estimated from cohort totals.
113#[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    // Both arms must exist for any comparison; otherwise there is no experiment.
123    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    // Safe: presence + counts checked above.
134    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
144/// Two-sample reduction with a Welch 95 % CI, or `None` if degenerate
145/// (control mean 0, or variance unavailable).
146fn 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; // tokens saved per turn (may be < 0)
158    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
173/// Model-based estimate band from the shared verbose/concise heuristic.
174fn 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    /// Builds a cohort from explicit per-turn samples (exact sums).
207    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                // Default heuristic 180→120 ⇒ 33.3% point.
227                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        // 40 turns/arm: control ~180, treatment ~120 → ~33% reduction.
264        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        // Constant per-arm output → variance 0 → CI collapses to the point.
292        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}