Skip to main content

lean_ctx/core/ocla/
routing_experiment.rs

1//! Weighted A/B experiments for model-routing variants.
2
3use std::collections::HashMap;
4
5use super::routing_quality::RoutingOutcome;
6
7const SUCCESS_THRESHOLD: f64 = 0.8;
8
9/// A model-routing variant participating in an experiment.
10#[derive(Clone, Debug, PartialEq)]
11pub struct ExperimentVariant {
12    pub name: String,
13    pub model: String,
14    pub weight: f64,
15}
16
17/// Aggregate evaluation for the best-performing experiment variant.
18#[derive(Clone, Debug, PartialEq)]
19pub struct ExperimentResult {
20    pub winner: String,
21    pub success_rate: f64,
22    pub avg_savings: f64,
23    pub sample_size: usize,
24}
25
26/// Collects routing outcomes and evaluates model variants.
27#[derive(Debug)]
28pub struct RoutingExperiment {
29    pub name: String,
30    variants: Vec<ExperimentVariant>,
31    outcomes: HashMap<String, Vec<RoutingOutcome>>,
32}
33
34impl RoutingExperiment {
35    /// Creates an experiment and normalizes its variant weights.
36    pub fn new(name: &str, mut variants: Vec<ExperimentVariant>) -> Self {
37        normalize_weights(&mut variants);
38        let outcomes = variants
39            .iter()
40            .map(|variant| (variant.name.clone(), Vec::new()))
41            .collect();
42
43        Self {
44            name: name.to_owned(),
45            variants,
46            outcomes,
47        }
48    }
49
50    /// Selects a variant using its normalized weight.
51    pub fn select_variant(&self) -> Option<&ExperimentVariant> {
52        let total_weight = self
53            .variants
54            .iter()
55            .map(|variant| variant.weight)
56            .sum::<f64>();
57        if total_weight <= 0.0 || !total_weight.is_finite() {
58            return None;
59        }
60
61        let mut target = random_unit_interval() * total_weight;
62        for variant in &self.variants {
63            if variant.weight > 0.0 {
64                if target < variant.weight {
65                    return Some(variant);
66                }
67                target -= variant.weight;
68            }
69        }
70
71        self.variants
72            .iter()
73            .rev()
74            .find(|variant| variant.weight > 0.0)
75    }
76
77    /// Records an outcome under the selected variant name.
78    pub fn record_outcome(&mut self, variant_name: &str, outcome: RoutingOutcome) {
79        self.outcomes
80            .entry(variant_name.to_owned())
81            .or_default()
82            .push(outcome);
83    }
84
85    /// Returns the variant with the best success-rate and savings score.
86    pub fn evaluate(&self) -> Option<ExperimentResult> {
87        let mut best_score = None;
88        let mut result = None;
89
90        for variant in &self.variants {
91            let Some(outcomes) = self.outcomes.get(&variant.name) else {
92                continue;
93            };
94            if outcomes.is_empty() {
95                continue;
96            }
97
98            let sample_size = outcomes.len();
99            let success_rate = outcomes
100                .iter()
101                .filter(|outcome| {
102                    outcome
103                        .quality_score
104                        .is_some_and(|score| score >= SUCCESS_THRESHOLD)
105                })
106                .count() as f64
107                / sample_size as f64;
108            let avg_savings = outcomes
109                .iter()
110                .map(|outcome| outcome.tokens_saved as f64)
111                .sum::<f64>()
112                / sample_size as f64;
113            let score = success_rate * avg_savings;
114
115            if best_score.is_none_or(|current| score > current) {
116                best_score = Some(score);
117                result = Some(ExperimentResult {
118                    winner: variant.name.clone(),
119                    success_rate,
120                    avg_savings,
121                    sample_size,
122                });
123            }
124        }
125
126        result
127    }
128
129    /// Returns the number of outcomes recorded for a variant.
130    pub fn sample_count(&self, variant_name: &str) -> usize {
131        self.outcomes.get(variant_name).map_or(0, Vec::len)
132    }
133
134    /// Returns whether every configured variant has enough observations.
135    pub fn is_conclusive(&self, min_samples: usize) -> bool {
136        !self.variants.is_empty()
137            && self
138                .variants
139                .iter()
140                .all(|variant| self.sample_count(&variant.name) >= min_samples)
141    }
142}
143
144fn normalize_weights(variants: &mut [ExperimentVariant]) {
145    let total_weight = variants
146        .iter()
147        .map(|variant| {
148            if variant.weight.is_finite() && variant.weight > 0.0 {
149                variant.weight
150            } else {
151                0.0
152            }
153        })
154        .sum::<f64>();
155
156    if total_weight.is_finite() && total_weight > 0.0 {
157        for variant in variants {
158            variant.weight = if variant.weight.is_finite() && variant.weight > 0.0 {
159                variant.weight / total_weight
160            } else {
161                0.0
162            };
163        }
164    } else {
165        for variant in variants {
166            variant.weight = 0.0;
167        }
168    }
169}
170
171fn random_unit_interval() -> f64 {
172    let mut bytes = [0_u8; 8];
173    if getrandom::fill(&mut bytes).is_err() {
174        tracing::warn!("secure random source unavailable; using midpoint selection");
175        return 0.5;
176    }
177
178    let value = u64::from_le_bytes(bytes) >> 11;
179    value as f64 / (1_u64 << 53) as f64
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::core::ocla::routing_quality::RoutingDecision;
186
187    fn variant(name: &str, weight: f64) -> ExperimentVariant {
188        ExperimentVariant {
189            name: name.into(),
190            model: format!("model-{name}"),
191            weight,
192        }
193    }
194
195    fn outcome(quality_score: Option<f64>, tokens_saved: u64) -> RoutingOutcome {
196        RoutingOutcome {
197            decision: RoutingDecision {
198                original_model: "baseline".into(),
199                routed_model: "candidate".into(),
200                reason: "experiment".into(),
201                timestamp: "2026-01-01T00:00:00Z".into(),
202            },
203            quality_score,
204            tokens_saved,
205            latency_delta_ms: 0,
206        }
207    }
208
209    #[test]
210    fn select_variant_respects_weights() {
211        let experiment = RoutingExperiment::new(
212            "weight-test",
213            vec![variant("heavy", 0.9), variant("light", 0.1)],
214        );
215        let heavy = (0..1_000)
216            .filter(|_| {
217                experiment
218                    .select_variant()
219                    .is_some_and(|selected| selected.name == "heavy")
220            })
221            .count();
222
223        assert!(heavy > 700, "heavy variant selected {heavy} times");
224    }
225
226    #[test]
227    fn evaluate_picks_best_variant() {
228        let mut experiment = RoutingExperiment::new(
229            "evaluation-test",
230            vec![variant("A", 0.5), variant("B", 0.5)],
231        );
232        for _ in 0..5 {
233            experiment.record_outcome("A", outcome(Some(1.0), 100));
234            experiment.record_outcome("B", outcome(Some(0.2), 100));
235        }
236
237        let result = experiment.evaluate().expect("outcomes should evaluate");
238        assert_eq!(result.winner, "A");
239        assert_eq!(result.success_rate, 1.0);
240        assert_eq!(result.avg_savings, 100.0);
241        assert_eq!(result.sample_size, 5);
242    }
243
244    #[test]
245    fn empty_experiment_returns_none() {
246        let experiment = RoutingExperiment::new("empty", vec![variant("A", 1.0)]);
247
248        assert_eq!(experiment.evaluate(), None);
249    }
250
251    #[test]
252    fn is_conclusive_requires_min_samples() {
253        let mut experiment = RoutingExperiment::new(
254            "conclusive-test",
255            vec![variant("A", 0.5), variant("B", 0.5)],
256        );
257
258        assert!(!experiment.is_conclusive(1));
259        experiment.record_outcome("A", outcome(Some(1.0), 10));
260        experiment.record_outcome("B", outcome(Some(1.0), 10));
261        assert!(!experiment.is_conclusive(2));
262        experiment.record_outcome("A", outcome(Some(1.0), 10));
263        experiment.record_outcome("B", outcome(Some(1.0), 10));
264        assert!(experiment.is_conclusive(2));
265    }
266
267    #[test]
268    fn record_and_retrieve() {
269        let mut experiment = RoutingExperiment::new("record-test", vec![variant("A", 1.0)]);
270
271        experiment.record_outcome("A", outcome(Some(1.0), 10));
272        experiment.record_outcome("A", outcome(Some(0.0), 5));
273
274        assert_eq!(experiment.sample_count("A"), 2);
275        assert_eq!(experiment.sample_count("missing"), 0);
276    }
277}