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) = (control.unwrap(), treatment.unwrap());
135    match measured(control, treatment) {
136        Some(m) => Savings::Measured(m),
137        None => estimated(),
138    }
139}
140
141/// Two-sample reduction with a Welch 95 % CI, or `None` if degenerate
142/// (control mean 0, or variance unavailable).
143fn 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; // tokens saved per turn (may be < 0)
155    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
170/// Model-based estimate band from the shared verbose/concise heuristic.
171fn 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    /// Builds a cohort from explicit per-turn samples (exact sums).
204    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                // Default heuristic 180→120 ⇒ 33.3% point.
224                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        // 40 turns/arm: control ~180, treatment ~120 → ~33% reduction.
261        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        // Constant per-arm output → variance 0 → CI collapses to the point.
289        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}