Skip to main content

lean_ctx/core/ocla/builtin/
experiment_executor.rs

1//! BuiltinExperimentExecutor — executes experiment assignments locally.
2//!
3//! Wraps `proxy/holdout.rs` behind the OCLA trait. Experiments are identified
4//! by deterministic refs. Results carry an outcome ref for correlation with
5//! the OutcomeTracker and an optional rollback ref for reverting the cohort.
6
7use 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
14// TODO(r19): replace with lean_ctx_protocol::*
15pub 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
62/// Executes a deterministic bucketing rule from a signed assignment.
63/// The runtime does NOT decide experiments — it executes assignments.
64pub(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
77/// Tracks experiment state for stop-condition evaluation.
78struct 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    /// Computes the outcome of an executed experiment arm.
170    /// Called AFTER execution to report results back to the sidecar.
171    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
255/// Backward-compatible name for callers using the previous runner API.
256pub type BuiltinExperimentRunner = BuiltinExperimentExecutor;
257
258/// Validates and executes a signed experiment assignment.
259/// Returns the arm to execute, or None if the assignment is invalid/expired.
260pub 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}