Skip to main content

lean_ctx_proxy/
output_savings.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Output-token savings reporting (#895 Track B).
3//!
4//! lean-ctx shapes *output* tokens (cache-safe effort control #834 and the
5//! optional verbosity steer). To report that honestly we distinguish two cases:
6//!
7//! * **Measured** — when a holdout ([`lean_ctx::core::config::ProxyConfig::output_holdout_fraction`])
8//!   has produced enough paired turns, the reduction is
9//!   `(control_avg − treatment_avg) / control_avg`, with a 95 % confidence
10//!   interval from a two-sample (Welch) standard error on the difference of mean
11//!   output tokens. This is a real A/B result, not a guess.
12//! * **Estimated** — with no holdout (or not enough data yet) we fall back to the
13//!   shared model-based heuristic and present it as a *band*, never a hard
14//!   number, clearly labelled so it is never mistaken for a measurement.
15
16use std::collections::HashMap;
17
18use super::usage_meter::CohortUsage;
19
20/// Minimum turns *per arm* before a measured reduction is reported. Below this
21/// the interval is too wide to be informative, so we report [`Savings::Pending`].
22pub const MIN_SAMPLES_PER_ARM: u64 = 30;
23
24/// Standard-normal 97.5th percentile (two-sided 95 % CI multiplier).
25const Z95: f64 = 1.959_964;
26
27/// Relative half-width applied to the model-based point estimate to express its
28/// inherent uncertainty as a band. This is an *estimate* parameter, not a
29/// measurement — enabling `output_holdout` replaces it with a real CI.
30const ESTIMATE_REL_UNCERTAINTY: f64 = 0.35;
31
32/// Outcome of an output-savings query, ready for the CLI / dashboard to render.
33#[derive(Debug, Clone, PartialEq)]
34pub enum Savings {
35    /// Real A/B result from the holdout.
36    Measured(Measured),
37    /// Holdout running but not enough paired turns yet.
38    Pending {
39        control_n: u64,
40        treatment_n: u64,
41        needed: u64,
42    },
43    /// No holdout: model-based estimate, presented as a band.
44    Estimated {
45        point_pct: f64,
46        low_pct: f64,
47        high_pct: f64,
48    },
49}
50
51/// A measured output-token reduction with its 95 % confidence interval.
52#[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/// Computes the output-savings outcome from the persisted cohort totals.
65#[must_use]
66pub fn current() -> Savings {
67    from_cohorts(&super::usage_meter::persisted_cohorts())
68}
69
70/// Stable JSON shape for a [`Savings`], shared by the `lean-ctx output-savings`
71/// CLI and the dashboard `/api/roi` route so both speak one schema.
72#[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/// Pure core: decide measured / pending / estimated from cohort totals.
114#[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    // Both arms must exist for any comparison; otherwise there is no experiment.
124    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    // Safe: presence + counts checked above.
135    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
145/// Two-sample reduction with a Welch 95 % CI, or `None` (not measured) if the
146/// sample is too small or degenerate: control mean 0, or a variance that is
147/// zero, undefined or inconsistent with the sums (a zero-width CI is not a
148/// measurement).
149fn 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/// Running totals of one per-turn quantity in one arm.
168#[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    /// Unbiased sample variance, clamped at 0 against floating-point error.
186    /// `None` with < 2 observations, or when the sum of squares cannot belong
187    /// to the sum (`n·Σx² < (Σx)²`) — e.g. totals whose squares were lost.
188    #[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
202/// Relative reduction of the per-turn mean from `control` to `treatment`, with
203/// a 95 % CI from the Welch standard error of the difference of means. Shared
204/// by the output-savings and input-compression holdouts. `None` if degenerate.
205pub(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; // tokens saved per turn (may be < 0)
217    let se = (var_c / n_c + var_t / n_t).sqrt();
218    // No spread means no interval: real per-turn token counts always vary, so
219    // a zero standard error signals lost or synthetic sums, not certainty.
220    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
237/// Model-based estimate band from the shared verbose/concise heuristic.
238fn 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    /// Builds a cohort from explicit per-turn samples (exact sums).
272    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                // Default heuristic 180→120 ⇒ 33.3% point.
292                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        // 40 turns/arm: control ~180, treatment ~120 → ~33% reduction.
329        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        // Constant per-arm output → variance 0 → a zero-width CI, which is no
357        // measurement at all.
358        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        // Pre-fix restarts dropped `sum_sq_output`: the totals then imply a
372        // negative variance. Those cohorts must read as not measured.
373        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}