1use crate::core::ocla::traits::{ExperimentRunner, OclaService};
8use crate::core::ocla::types::{
9 ExperimentOutcome, ExperimentRequest, ExperimentResult, ExperimentStopConditions,
10 OclaCapability, OclaCapabilityKind, OclaResult,
11};
12use serde::{Deserialize, Serialize};
13
14pub type CurrencyCode = String;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct MoneyV1 {
19 pub currency: CurrencyCode,
20 pub coefficient: i128,
21 pub scale: u8,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum ExperimentArm {
26 Control,
27 Optimized,
28 Shadow,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub enum DataClassification {
33 Public,
34 Internal,
35 Confidential,
36 Restricted,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub enum SideEffectPolicy {
41 NoSideEffects,
42 ReadOnly,
43 AllowWrites,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct ExperimentAssignmentV1 {
48 pub experiment_id: String,
49 pub subject_id: String,
50 pub arm: ExperimentArm,
51 pub configuration_ref: String,
52 pub expires_at: String,
53 pub max_incremental_cost: MoneyV1,
54 pub allowed_providers: Vec<String>,
55 pub allowed_models: Vec<String>,
56 pub data_classification: DataClassification,
57 pub side_effect_policy: SideEffectPolicy,
58 pub kill_switch_ref: String,
59 pub signature: String,
60}
61
62pub(crate) fn execute_bucketing_rule(seed: &str, subject: &str, holdout_pct: u8) -> bool {
65 let mut hasher = blake3::Hasher::new();
66 hasher.update(seed.as_bytes());
67 hasher.update(subject.as_bytes());
68 let hash = hasher.finalize();
69 let bucket = u64::from_le_bytes(
70 hash.as_bytes()[..8]
71 .try_into()
72 .expect("blake3 hash is at least 8 bytes"),
73 ) % 100;
74 bucket < u64::from(holdout_pct)
75}
76
77struct ExperimentState {
79 samples: u64,
80 started_at: std::time::Instant,
81 treatment_sum: f64,
82 control_sum: f64,
83 treatment_count: u64,
84 control_count: u64,
85}
86
87impl ExperimentState {
88 fn new() -> Self {
89 Self {
90 samples: 0,
91 started_at: std::time::Instant::now(),
92 treatment_sum: 0.0,
93 control_sum: 0.0,
94 treatment_count: 0,
95 control_count: 0,
96 }
97 }
98
99 fn should_stop(&self, conditions: &ExperimentStopConditions) -> Option<String> {
100 if conditions
101 .max_samples
102 .is_some_and(|max| self.samples >= max)
103 {
104 return Some("max_samples".into());
105 }
106 if conditions
107 .max_duration_secs
108 .is_some_and(|max| self.started_at.elapsed().as_secs() >= max)
109 {
110 return Some("max_duration_secs".into());
111 }
112 if let Some(min_improvement_pct) = conditions.min_improvement_pct {
113 let outcome = self.outcome("");
114 if self.control_count > 0
115 && self.treatment_count > 0
116 && outcome.improvement_pct < f64::from(min_improvement_pct)
117 {
118 return Some("min_improvement_pct".into());
119 }
120 }
121 None
122 }
123
124 fn record_sample(&mut self, is_holdout: bool, metric: f64) {
125 self.samples += 1;
126 if is_holdout {
127 self.control_sum += metric;
128 self.control_count += 1;
129 } else {
130 self.treatment_sum += metric;
131 self.treatment_count += 1;
132 }
133 }
134
135 fn outcome(&self, experiment_ref: &str) -> ExperimentOutcome {
136 let treatment_metric = average(self.treatment_sum, self.treatment_count);
137 let control_metric = average(self.control_sum, self.control_count);
138 let improvement_pct = if self.control_count == 0 || control_metric == 0.0 {
139 0.0
140 } else {
141 (treatment_metric - control_metric) / control_metric * 100.0
142 };
143 ExperimentOutcome {
144 experiment_ref: experiment_ref.into(),
145 treatment_samples: self.treatment_count,
146 control_samples: self.control_count,
147 treatment_metric,
148 control_metric,
149 improvement_pct,
150 stopped_reason: None,
151 is_significant: self.treatment_count > 0
152 && self.control_count > 0
153 && treatment_metric != control_metric,
154 }
155 }
156}
157
158fn average(sum: f64, count: u64) -> f64 {
159 if count == 0 { 0.0 } else { sum / count as f64 }
160}
161
162pub struct BuiltinExperimentExecutor;
163
164impl BuiltinExperimentExecutor {
165 pub fn new() -> Self {
166 Self
167 }
168
169 pub fn compute_outcome(
172 &self,
173 request: &ExperimentRequest,
174 metric_fn: impl Fn(&str) -> f64,
175 ) -> OclaResult<ExperimentOutcome> {
176 let holdout_samples = request
177 .holdout
178 .as_ref()
179 .and_then(|holdout| holdout.max_samples);
180 let stop_samples = request
181 .stop_conditions
182 .as_ref()
183 .and_then(|conditions| conditions.max_samples);
184 let sample_count = match (holdout_samples, stop_samples) {
185 (Some(holdout), Some(stop)) => holdout.min(stop),
186 (Some(samples), None) | (None, Some(samples)) => samples,
187 (None, None) => 1,
188 };
189 let mut state = ExperimentState::new();
190 let mut stopped_reason = None;
191
192 for sample in 0..sample_count {
193 let request_ref = format!("{}:{sample}", request.context.request_id);
194 let is_holdout = request.holdout.as_ref().is_some_and(|holdout| {
195 execute_bucketing_rule(&holdout.assignment_seed, &request_ref, holdout.holdout_pct)
196 });
197 state.record_sample(is_holdout, metric_fn(&request_ref));
198 if let Some(conditions) = request.stop_conditions.as_ref()
199 && let Some(reason) = state.should_stop(conditions)
200 {
201 stopped_reason = Some(reason);
202 break;
203 }
204 }
205
206 let mut outcome = state.outcome(&request.experiment_ref);
207 outcome.stopped_reason = stopped_reason;
208 Ok(outcome)
209 }
210}
211
212impl Default for BuiltinExperimentExecutor {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218impl OclaService for BuiltinExperimentExecutor {
219 fn capability(&self) -> OclaCapability {
220 OclaCapability::available(OclaCapabilityKind::ExperimentRunner)
221 }
222}
223
224impl ExperimentRunner for BuiltinExperimentExecutor {
225 fn run_experiment(&self, request: ExperimentRequest) -> OclaResult<ExperimentResult> {
226 let config = crate::core::config::Config::load();
227 let requested_model = config
228 .proxy
229 .baseline
230 .reference_model
231 .as_deref()
232 .ok_or_else(|| {
233 crate::core::ocla::types::OclaError::Rejected(
234 OclaCapabilityKind::ExperimentRunner,
235 "no reference model configured for routing evaluation".into(),
236 )
237 })?;
238 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
239
240 crate::core::eval_ab::routing_eval::run_routing_experiment(
241 &request,
242 requested_model,
243 &config.proxy.routing,
244 &pricing,
245 )
246 .map_err(|error| {
247 crate::core::ocla::types::OclaError::Rejected(
248 OclaCapabilityKind::ExperimentRunner,
249 error.to_string(),
250 )
251 })
252 }
253}
254
255pub type BuiltinExperimentRunner = BuiltinExperimentExecutor;
257
258pub fn execute_assignment(
261 assignment: &ExperimentAssignmentV1,
262 subject_ref: &str,
263 now: &str,
264) -> Option<ExperimentArm> {
265 if assignment.subject_id != subject_ref
266 || now > assignment.expires_at.as_str()
267 || assignment.kill_switch_ref == "KILLED"
268 {
269 return None;
270 }
271
272 Some(assignment.arm.clone())
273}
274
275#[cfg(test)]
276mod tests {
277 use super::{
278 BuiltinExperimentExecutor, DataClassification, ExperimentArm, ExperimentAssignmentV1,
279 ExperimentState, MoneyV1, SideEffectPolicy, execute_assignment, execute_bucketing_rule,
280 };
281 use crate::core::ocla::traits::ExperimentRunner;
282 use crate::core::ocla::types::{
283 ExperimentRequest, ExperimentStopConditions, HoldoutConfig, OclaRequestContext,
284 };
285
286 fn experiment(name: &str) -> ExperimentRequest {
287 ExperimentRequest {
288 context: OclaRequestContext {
289 request_id: "r1".into(),
290 session_id: "s1".into(),
291 agent_id: "agent-test".into(),
292 content_ref: "ref:test".into(),
293 tenant_id: None,
294 trace_id: "tr-unit".into(),
295 },
296 experiment_ref: name.into(),
297 cohort_ref: "cohort:control".into(),
298 holdout: None,
299 stop_conditions: None,
300 }
301 }
302
303 #[test]
304 fn holdout_assignment_is_deterministic() {
305 assert_eq!(
306 execute_bucketing_rule("seed", "request-1", 50),
307 execute_bucketing_rule("seed", "request-1", 50)
308 );
309 }
310
311 #[test]
312 fn distinct_seeds_produce_distinct_assignments() {
313 assert!((0..100).any(|index| {
314 let request_ref = format!("request-{index}");
315 execute_bucketing_rule("seed-a", &request_ref, 50)
316 != execute_bucketing_rule("seed-b", &request_ref, 50)
317 }));
318 }
319
320 #[test]
321 fn max_samples_stops_experiment() {
322 let mut state = ExperimentState::new();
323 state.record_sample(false, 1.0);
324 let conditions = ExperimentStopConditions {
325 max_samples: Some(1),
326 min_improvement_pct: None,
327 max_duration_secs: None,
328 };
329 assert_eq!(
330 state.should_stop(&conditions).as_deref(),
331 Some("max_samples")
332 );
333 }
334
335 #[test]
336 fn empty_stop_conditions_never_stop() {
337 let mut state = ExperimentState::new();
338 state.record_sample(false, 1.0);
339 let conditions = ExperimentStopConditions {
340 max_samples: None,
341 min_improvement_pct: None,
342 max_duration_secs: None,
343 };
344 assert_eq!(state.should_stop(&conditions), None);
345 }
346
347 #[test]
348 fn compute_outcome_returns_holdout_metrics() {
349 let runner = BuiltinExperimentExecutor::new();
350 let mut request = experiment("exp-outcome");
351 request.holdout = Some(HoldoutConfig {
352 holdout_pct: 50,
353 assignment_seed: "seed".into(),
354 max_samples: Some(100),
355 });
356 let outcome = runner.compute_outcome(&request, |_| 10.0).unwrap();
357
358 assert_eq!(outcome.treatment_samples + outcome.control_samples, 100);
359 assert_eq!(outcome.treatment_metric, 10.0);
360 assert_eq!(outcome.control_metric, 10.0);
361 assert_eq!(outcome.improvement_pct, 0.0);
362 }
363
364 #[test]
365 fn rejects_missing_suite_instead_of_fabricating_result() {
366 let runner = BuiltinExperimentExecutor::new();
367 let error = runner.run_experiment(experiment("/definitely/missing-suite.ndjson"));
368 assert!(error.is_err());
369 }
370
371 #[test]
372 fn invalid_request_never_returns_synthetic_refs() {
373 let runner = BuiltinExperimentExecutor::new();
374 let result = runner.run_experiment(experiment("exp-b"));
375 assert!(result.is_err());
376 }
377
378 #[test]
379 fn registry_builtins_route_experiment_requests_to_runner() {
380 let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
381 let result = registry
382 .experiment_runner
383 .run_experiment(experiment("/definitely/missing-suite.ndjson"));
384 assert!(result.is_err());
385 }
386
387 fn assignment(arm: ExperimentArm) -> ExperimentAssignmentV1 {
388 ExperimentAssignmentV1 {
389 experiment_id: "exp-1".into(),
390 subject_id: "subject-1".into(),
391 arm,
392 configuration_ref: "config-1".into(),
393 expires_at: "2026-08-06T00:00:00Z".into(),
394 max_incremental_cost: MoneyV1 {
395 currency: "USD".into(),
396 coefficient: 100,
397 scale: 2,
398 },
399 allowed_providers: vec!["provider-1".into()],
400 allowed_models: vec!["model-1".into()],
401 data_classification: DataClassification::Internal,
402 side_effect_policy: SideEffectPolicy::NoSideEffects,
403 kill_switch_ref: "ACTIVE".into(),
404 signature: "transport-verified".into(),
405 }
406 }
407
408 #[test]
409 fn test_execute_assignment_expired() {
410 let assignment = assignment(ExperimentArm::Optimized);
411 assert_eq!(
412 execute_assignment(&assignment, "subject-1", "2026-08-07T00:00:00Z"),
413 None
414 );
415 }
416
417 #[test]
418 fn test_execute_assignment_killed() {
419 let mut assignment = assignment(ExperimentArm::Optimized);
420 assignment.kill_switch_ref = "KILLED".into();
421 assert_eq!(
422 execute_assignment(&assignment, "subject-1", "2026-08-05T00:00:00Z"),
423 None
424 );
425 }
426
427 #[test]
428 fn test_execute_assignment_valid() {
429 let assignment = assignment(ExperimentArm::Shadow);
430 assert_eq!(
431 execute_assignment(&assignment, "subject-1", "2026-08-05T00:00:00Z"),
432 Some(ExperimentArm::Shadow)
433 );
434 }
435}