lean_ctx/core/ocla/
routing_experiment.rs1use std::collections::HashMap;
4
5use super::routing_quality::RoutingOutcome;
6
7const SUCCESS_THRESHOLD: f64 = 0.8;
8
9#[derive(Clone, Debug, PartialEq)]
11pub struct ExperimentVariant {
12 pub name: String,
13 pub model: String,
14 pub weight: f64,
15}
16
17#[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#[derive(Debug)]
28pub struct RoutingExperiment {
29 pub name: String,
30 variants: Vec<ExperimentVariant>,
31 outcomes: HashMap<String, Vec<RoutingOutcome>>,
32}
33
34impl RoutingExperiment {
35 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 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 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 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 pub fn sample_count(&self, variant_name: &str) -> usize {
131 self.outcomes.get(variant_name).map_or(0, Vec::len)
132 }
133
134 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}