1use crate::ai::{CompiledModel, RouteTarget};
42use crate::config::ai::{AiAbTest, AiEval, AiExperiments};
43use bytes::Bytes;
44use http_body_util::{BodyExt as _, Full};
45use hyper::{Method, Request};
46use hyper_util::client::legacy::Client;
47use hyper_util::rt::TokioExecutor;
48use std::collections::BTreeMap;
49use std::time::Duration;
50
51#[derive(Debug, Clone)]
55pub struct CompiledAbVariant {
56 pub name: String,
58 pub target: RouteTarget,
60 pub system_message: Option<String>,
63 pub weight: u32,
65}
66
67#[derive(Debug, Clone)]
72pub struct CompiledAbTest {
73 pub name: String,
75 pub variants: Vec<CompiledAbVariant>,
77}
78
79impl CompiledAbTest {
80 pub fn compile(
89 name: &str,
90 test: &AiAbTest,
91 models: &BTreeMap<String, CompiledModel>,
92 experiments: Option<&AiExperiments>,
93 ) -> Option<Self> {
94 let mut variants = Vec::with_capacity(test.variants.len());
95 for v in &test.variants {
96 let target = primary_target_of(models, &v.model)?.clone();
97 let system_message = v
98 .prompt
99 .as_ref()
100 .and_then(|ref_str| resolve_prompt_version(experiments, ref_str));
101 variants.push(CompiledAbVariant {
102 name: v.name.clone(),
103 target,
104 system_message,
105 weight: v.weight,
106 });
107 }
108 Some(CompiledAbTest {
109 name: name.to_string(),
110 variants,
111 })
112 }
113
114 pub fn pick(&self, pick_key: &str) -> &CompiledAbVariant {
119 let entries: Vec<(u32, &CompiledAbVariant)> =
120 self.variants.iter().map(|v| (v.weight, v)).collect();
121 let total: u64 = entries.iter().map(|(w, _)| u64::from(*w)).sum();
126 debug_assert!(total > 0, "validation guarantees positive total weight");
127 let slot = crate::ai::routing::pick_hash(pick_key) % total;
128 let mut bound = 0u64;
129 for (w, variant) in &entries {
130 bound += u64::from(*w);
131 if slot < bound {
132 return variant;
133 }
134 }
135 &self.variants[0]
138 }
139}
140
141#[derive(Debug, Clone)]
145pub struct ExperimentDecision {
146 pub experiment: String,
148 pub variant: String,
150 pub model: String,
152}
153
154fn primary_target_of<'a>(
159 models: &'a BTreeMap<String, CompiledModel>,
160 alias: &str,
161) -> Option<&'a RouteTarget> {
162 match models.get(alias)? {
163 CompiledModel::Chain(chain) => chain.first(),
164 CompiledModel::Canary(versions) => versions.first().map(|(_, t)| t),
165 CompiledModel::Policy(_) => None,
169 CompiledModel::Experiment(_) => None,
170 }
171}
172
173fn resolve_prompt_version(experiments: Option<&AiExperiments>, reference: &str) -> Option<String> {
178 let exp = experiments?;
179 let (prompt_name, version_name) = reference.split_once('/')?;
180 let prompt = exp.prompts.get(prompt_name)?;
181 let version = prompt.versions.get(version_name)?;
182 Some(version.system.clone())
183}
184
185pub fn active_prompt_system(
189 experiments: Option<&AiExperiments>,
190 overrides: &[(String, String)],
191 prompt_name: &str,
192) -> Option<String> {
193 let exp = experiments?;
194 let prompt = exp.prompts.get(prompt_name)?;
195 let active_version = overrides
197 .iter()
198 .find(|(name, _)| name == prompt_name)
199 .map(|(_, version)| version.clone())
200 .unwrap_or_else(|| prompt.active.clone());
201 let version = prompt.versions.get(&active_version)?;
202 Some(version.system.clone())
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum EvalScorer {
212 ExactMatch,
214 Contains,
216 Regex,
218 LlmJudge,
224 SemanticSimilarity,
231}
232
233impl EvalScorer {
234 pub fn parse(s: Option<&str>) -> Self {
237 match s {
238 Some("contains") => EvalScorer::Contains,
239 Some("regex") => EvalScorer::Regex,
240 Some("llm_judge") => EvalScorer::LlmJudge,
241 Some("semantic_similarity") => EvalScorer::SemanticSimilarity,
242 _ => EvalScorer::ExactMatch,
243 }
244 }
245
246 pub fn as_str(&self) -> &'static str {
248 match self {
249 EvalScorer::ExactMatch => "exact_match",
250 EvalScorer::Contains => "contains",
251 EvalScorer::Regex => "regex",
252 EvalScorer::LlmJudge => "llm_judge",
253 EvalScorer::SemanticSimilarity => "semantic_similarity",
254 }
255 }
256
257 pub fn score(&self, output: &str, expected: &str) -> bool {
263 match self {
264 EvalScorer::ExactMatch => output.trim() == expected.trim(),
265 EvalScorer::Contains => output.contains(expected),
266 EvalScorer::Regex => regex::Regex::new(expected)
267 .map(|re| re.is_match(output))
268 .unwrap_or(false),
269 EvalScorer::LlmJudge => {
270 output.contains(expected)
274 }
275 EvalScorer::SemanticSimilarity => jaccard_similarity(output, expected) >= 0.5,
276 }
277 }
278}
279
280fn jaccard_similarity(a: &str, b: &str) -> f64 {
283 let set_a: std::collections::HashSet<&str> = a.split_whitespace().collect();
284 let set_b: std::collections::HashSet<&str> = b.split_whitespace().collect();
285 if set_a.is_empty() && set_b.is_empty() {
286 return 1.0;
287 }
288 let intersection = set_a.intersection(&set_b).count();
289 let union = set_a.union(&set_b).count();
290 if union == 0 {
291 return 0.0;
292 }
293 intersection as f64 / union as f64
294}
295
296#[derive(Debug, Clone)]
299pub struct EvalCaseResult {
300 pub case_index: usize,
302 pub input: String,
304 pub expected: String,
306 pub actual: String,
308 pub passed: bool,
310 pub scorer: EvalScorer,
312 pub latency_ms: f64,
314}
315
316#[derive(Debug, Clone)]
318pub struct EvalRunResult {
319 pub eval_name: String,
321 pub model: String,
323 pub variant: String,
326 pub prompt_version: String,
328 pub cases: Vec<EvalCaseResult>,
330}
331
332impl EvalRunResult {
333 pub fn pass_rate(&self) -> f64 {
335 if self.cases.is_empty() {
336 return 0.0;
337 }
338 let passed = self.cases.iter().filter(|c| c.passed).count();
339 passed as f64 / self.cases.len() as f64
340 }
341
342 pub fn passed_count(&self) -> usize {
344 self.cases.iter().filter(|c| c.passed).count()
345 }
346
347 pub fn avg_latency_ms(&self) -> f64 {
349 if self.cases.is_empty() {
350 return 0.0;
351 }
352 let total: f64 = self.cases.iter().map(|c| c.latency_ms).sum();
353 total / self.cases.len() as f64
354 }
355}
356
357#[derive(Debug, Clone)]
363pub struct ExperimentVerdict {
364 pub experiment: String,
366 pub winner: Option<String>,
369 pub pass_rates: Vec<(String, f64)>,
371 pub avg_latencies: Vec<(String, f64)>,
373}
374
375pub fn compute_verdict(experiment: &str, results: &[EvalRunResult]) -> ExperimentVerdict {
381 let pass_rates: Vec<(String, f64)> = results
382 .iter()
383 .map(|r| (r.variant.clone(), r.pass_rate()))
384 .collect();
385 let avg_latencies: Vec<(String, f64)> = results
386 .iter()
387 .map(|r| (r.variant.clone(), r.avg_latency_ms()))
388 .collect();
389 let winner = if results.len() < 2 {
390 results.first().map(|r| r.variant.clone())
392 } else {
393 let mut sorted: Vec<&EvalRunResult> = results.iter().collect();
395 sorted.sort_by(|a, b| {
396 b.pass_rate()
397 .partial_cmp(&a.pass_rate())
398 .unwrap_or(std::cmp::Ordering::Equal)
399 .then_with(|| {
400 a.avg_latency_ms()
401 .partial_cmp(&b.avg_latency_ms())
402 .unwrap_or(std::cmp::Ordering::Equal)
403 })
404 });
405 let (first, second) = (&sorted[0], &sorted[1]);
406 if (first.pass_rate() - second.pass_rate()).abs() < f64::EPSILON
407 && (first.avg_latency_ms() - second.avg_latency_ms()).abs() < f64::EPSILON
408 {
409 None
411 } else {
412 Some(first.variant.clone())
413 }
414 };
415 ExperimentVerdict {
416 experiment: experiment.to_string(),
417 winner,
418 pass_rates,
419 avg_latencies,
420 }
421}
422
423#[derive(Debug, Clone)]
427pub struct AutoPromotionConfig {
428 pub min_improvement: f64,
432 pub min_cases: usize,
435}
436
437impl Default for AutoPromotionConfig {
438 fn default() -> Self {
439 Self {
440 min_improvement: 0.05,
441 min_cases: 10,
442 }
443 }
444}
445
446#[derive(Debug, Clone)]
448pub struct AutoPromotionDecision {
449 pub experiment: String,
451 pub promote: Option<String>,
453 pub reason: String,
455}
456
457pub fn evaluate_auto_promotion(
461 experiment: &str,
462 results: &[EvalRunResult],
463 config: &AutoPromotionConfig,
464) -> AutoPromotionDecision {
465 if results.len() < 2 {
466 return AutoPromotionDecision {
467 experiment: experiment.to_string(),
468 promote: None,
469 reason: "insufficient variants for comparison".to_string(),
470 };
471 }
472 let case_count = results.first().map(|r| r.cases.len()).unwrap_or(0);
474 if case_count < config.min_cases {
475 return AutoPromotionDecision {
476 experiment: experiment.to_string(),
477 promote: None,
478 reason: format!("insufficient cases ({case_count} < {})", config.min_cases),
479 };
480 }
481 let mut sorted: Vec<&EvalRunResult> = results.iter().collect();
483 sorted.sort_by(|a, b| {
484 b.pass_rate()
485 .partial_cmp(&a.pass_rate())
486 .unwrap_or(std::cmp::Ordering::Equal)
487 .then_with(|| {
488 a.avg_latency_ms()
489 .partial_cmp(&b.avg_latency_ms())
490 .unwrap_or(std::cmp::Ordering::Equal)
491 })
492 });
493 let winner = &sorted[0];
494 let baseline = &sorted[1];
495 let improvement = winner.pass_rate() - baseline.pass_rate();
496 if improvement >= config.min_improvement {
497 AutoPromotionDecision {
498 experiment: experiment.to_string(),
499 promote: Some(winner.variant.clone()),
500 reason: format!(
501 "winner '{}' has {:.1}% higher pass rate than baseline '{}'",
502 winner.variant,
503 improvement * 100.0,
504 baseline.variant
505 ),
506 }
507 } else {
508 AutoPromotionDecision {
509 experiment: experiment.to_string(),
510 promote: None,
511 reason: format!(
512 "improvement {:.1}% below threshold {:.1}%",
513 improvement * 100.0,
514 config.min_improvement * 100.0
515 ),
516 }
517 }
518}
519
520fn eval_http_client() -> Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>> {
523 Client::builder(TokioExecutor::new()).build_http::<Full<Bytes>>()
524}
525
526async fn run_eval_case(
533 client: &Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>>,
534 provider_url: &str,
535 auth_header: Option<&str>,
536 provider_model: &str,
537 system_message: Option<&str>,
538 input: &str,
539 timeout: Duration,
540) -> Result<String, String> {
541 let mut messages = Vec::new();
542 if let Some(sys) = system_message {
543 messages.push(serde_json::json!({
544 "role": "system",
545 "content": sys,
546 }));
547 }
548 messages.push(serde_json::json!({
549 "role": "user",
550 "content": input,
551 }));
552 let body = serde_json::json!({
553 "model": provider_model,
554 "messages": messages,
555 "stream": false,
556 });
557 let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("json encode: {e}"))?;
558 let mut req = Request::builder()
559 .method(Method::POST)
560 .uri(provider_url)
561 .header("content-type", "application/json")
562 .header("accept", "application/json");
563 if let Some(auth) = auth_header {
564 req = req.header("authorization", auth);
565 }
566 let req = req
567 .body(Full::new(Bytes::from(body_bytes)))
568 .map_err(|e| format!("request build: {e}"))?;
569 let resp = tokio::time::timeout(timeout, client.request(req))
570 .await
571 .map_err(|_| format!("eval request timed out after {}ms", timeout.as_millis()))?
572 .map_err(|e| format!("eval request failed: {e}"))?;
573 let status = resp.status();
574 let bytes = resp
575 .into_body()
576 .collect()
577 .await
578 .map_err(|e| format!("eval response body read: {e}"))?
579 .to_bytes();
580 if !status.is_success() {
581 return Err(format!(
582 "eval request returned status {}: {}",
583 status,
584 String::from_utf8_lossy(&bytes)
585 ));
586 }
587 let value: serde_json::Value =
588 serde_json::from_slice(&bytes).map_err(|e| format!("eval response json parse: {e}"))?;
589 let content = value
591 .get("choices")
592 .and_then(|c| c.get(0))
593 .and_then(|c| c.get("message"))
594 .and_then(|m| m.get("content"))
595 .and_then(|c| c.as_str())
596 .unwrap_or("");
597 Ok(content.to_string())
598}
599
600#[allow(clippy::too_many_arguments)]
607pub async fn run_eval(
608 eval_name: &str,
609 eval: &AiEval,
610 model: &str,
611 variant: &str,
612 provider_url: &str,
613 auth_header: Option<&str>,
614 provider_model: &str,
615 system_message: Option<&str>,
616 timeout_ms: u64,
617) -> EvalRunResult {
618 let client = eval_http_client();
619 let timeout = Duration::from_millis(timeout_ms.max(1));
620 let mut cases = Vec::with_capacity(eval.golden_set.len());
621 for (i, case) in eval.golden_set.iter().enumerate() {
622 let scorer = EvalScorer::parse(case.scorer.as_deref());
623 let start = std::time::Instant::now();
624 let actual = match run_eval_case(
625 &client,
626 provider_url,
627 auth_header,
628 provider_model,
629 system_message,
630 &case.input,
631 timeout,
632 )
633 .await
634 {
635 Ok(output) => output,
636 Err(e) => {
637 tracing::warn!(
638 code = "eval_case_failed",
639 eval = %eval_name,
640 case_index = i,
641 "eval case failed: {e}"
642 );
643 e
644 }
645 };
646 let latency_ms = start.elapsed().as_millis() as f64;
647 let passed = scorer.score(&actual, &case.expected);
648 cases.push(EvalCaseResult {
649 case_index: i,
650 input: case.input.clone(),
651 expected: case.expected.clone(),
652 actual,
653 passed,
654 scorer,
655 latency_ms,
656 });
657 }
658 EvalRunResult {
659 eval_name: eval_name.to_string(),
660 model: model.to_string(),
661 variant: variant.to_string(),
662 prompt_version: eval.prompt.clone().unwrap_or_default(),
663 cases,
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::ai::{CompiledModel, RouteTarget};
671 use crate::config::ai::{
672 AiAbTest, AiAbVariant, AiExperiments, AiPromptVersion, AiPromptVersions,
673 };
674
675 fn make_chain_model(provider: &str, model: &str) -> CompiledModel {
676 CompiledModel::Chain(vec![RouteTarget {
677 provider: provider.to_string(),
678 provider_model: model.to_string(),
679 version: None,
680 }])
681 }
682
683 fn make_models() -> BTreeMap<String, CompiledModel> {
684 let mut m = BTreeMap::new();
685 m.insert("model-a".to_string(), make_chain_model("p1", "gpt-4o"));
686 m.insert("model-b".to_string(), make_chain_model("p2", "claude"));
687 m
688 }
689
690 #[test]
691 fn compiled_ab_test_pick_is_deterministic() {
692 let models = make_models();
693 let test = AiAbTest {
694 variants: vec![
695 AiAbVariant {
696 name: "control".to_string(),
697 model: "model-a".to_string(),
698 prompt: None,
699 weight: 1,
700 },
701 AiAbVariant {
702 name: "treatment".to_string(),
703 model: "model-b".to_string(),
704 prompt: None,
705 weight: 1,
706 },
707 ],
708 };
709 let compiled = CompiledAbTest::compile("my-test", &test, &models, None).unwrap();
710 assert_eq!(compiled.variants.len(), 2);
711 let pick1 = compiled.pick("req-001");
713 let pick2 = compiled.pick("req-001");
714 assert_eq!(pick1.name, pick2.name);
715 }
716
717 #[test]
718 fn compiled_ab_test_resolves_prompt_version() {
719 let models = make_models();
720 let experiments = AiExperiments {
721 prompts: {
722 let mut m = BTreeMap::new();
723 m.insert(
724 "greeting".to_string(),
725 AiPromptVersions {
726 versions: {
727 let mut v = BTreeMap::new();
728 v.insert(
729 "v1".to_string(),
730 AiPromptVersion {
731 system: "You are a helpful assistant.".to_string(),
732 },
733 );
734 v
735 },
736 active: "v1".to_string(),
737 },
738 );
739 m
740 },
741 ..Default::default()
742 };
743 let test = AiAbTest {
744 variants: vec![AiAbVariant {
745 name: "v1".to_string(),
746 model: "model-a".to_string(),
747 prompt: Some("greeting/v1".to_string()),
748 weight: 1,
749 }],
750 };
751 let compiled = CompiledAbTest::compile("test", &test, &models, Some(&experiments)).unwrap();
752 assert_eq!(
753 compiled.variants[0].system_message.as_deref(),
754 Some("You are a helpful assistant.")
755 );
756 }
757
758 #[test]
759 fn compiled_ab_test_rejects_nested_experiment_alias() {
760 let models = make_models();
761 let test = AiAbTest {
766 variants: vec![AiAbVariant {
767 name: "v1".to_string(),
768 model: "model-a".to_string(),
769 prompt: None,
770 weight: 1,
771 }],
772 };
773 let compiled = CompiledAbTest::compile("test", &test, &models, None);
774 assert!(compiled.is_some());
775 }
776
777 #[test]
778 fn eval_scorer_exact_match() {
779 assert!(EvalScorer::ExactMatch.score("hello", "hello"));
780 assert!(EvalScorer::ExactMatch.score(" hello ", "hello"));
781 assert!(!EvalScorer::ExactMatch.score("hello", "world"));
782 }
783
784 #[test]
785 fn eval_scorer_contains() {
786 assert!(EvalScorer::Contains.score("hello world", "world"));
787 assert!(!EvalScorer::Contains.score("hello", "world"));
788 }
789
790 #[test]
791 fn eval_scorer_regex() {
792 assert!(EvalScorer::Regex.score("hello123", r"\d+"));
793 assert!(!EvalScorer::Regex.score("hello", r"\d+"));
794 }
795
796 #[test]
797 fn eval_pass_rate() {
798 let result = EvalRunResult {
799 eval_name: "test".to_string(),
800 model: "m".to_string(),
801 variant: "v".to_string(),
802 prompt_version: String::new(),
803 cases: vec![
804 EvalCaseResult {
805 case_index: 0,
806 input: "a".to_string(),
807 expected: "a".to_string(),
808 actual: "a".to_string(),
809 passed: true,
810 scorer: EvalScorer::ExactMatch,
811 latency_ms: 10.0,
812 },
813 EvalCaseResult {
814 case_index: 1,
815 input: "b".to_string(),
816 expected: "b".to_string(),
817 actual: "x".to_string(),
818 passed: false,
819 scorer: EvalScorer::ExactMatch,
820 latency_ms: 20.0,
821 },
822 ],
823 };
824 assert_eq!(result.pass_rate(), 0.5);
825 assert_eq!(result.passed_count(), 1);
826 assert_eq!(result.avg_latency_ms(), 15.0);
827 }
828
829 #[test]
830 fn verdict_picks_higher_pass_rate() {
831 let results = vec![
832 EvalRunResult {
833 eval_name: "e".to_string(),
834 model: "m".to_string(),
835 variant: "a".to_string(),
836 prompt_version: String::new(),
837 cases: vec![EvalCaseResult {
838 case_index: 0,
839 input: "i".to_string(),
840 expected: "e".to_string(),
841 actual: "e".to_string(),
842 passed: true,
843 scorer: EvalScorer::ExactMatch,
844 latency_ms: 100.0,
845 }],
846 },
847 EvalRunResult {
848 eval_name: "e".to_string(),
849 model: "m".to_string(),
850 variant: "b".to_string(),
851 prompt_version: String::new(),
852 cases: vec![EvalCaseResult {
853 case_index: 0,
854 input: "i".to_string(),
855 expected: "e".to_string(),
856 actual: "x".to_string(),
857 passed: false,
858 scorer: EvalScorer::ExactMatch,
859 latency_ms: 50.0,
860 }],
861 },
862 ];
863 let verdict = compute_verdict("test", &results);
864 assert_eq!(verdict.winner.as_deref(), Some("a"));
865 }
866
867 #[test]
868 fn verdict_tiebreaks_on_latency() {
869 let results = vec![
870 EvalRunResult {
871 eval_name: "e".to_string(),
872 model: "m".to_string(),
873 variant: "a".to_string(),
874 prompt_version: String::new(),
875 cases: vec![EvalCaseResult {
876 case_index: 0,
877 input: "i".to_string(),
878 expected: "e".to_string(),
879 actual: "e".to_string(),
880 passed: true,
881 scorer: EvalScorer::ExactMatch,
882 latency_ms: 100.0,
883 }],
884 },
885 EvalRunResult {
886 eval_name: "e".to_string(),
887 model: "m".to_string(),
888 variant: "b".to_string(),
889 prompt_version: String::new(),
890 cases: vec![EvalCaseResult {
891 case_index: 0,
892 input: "i".to_string(),
893 expected: "e".to_string(),
894 actual: "e".to_string(),
895 passed: true,
896 scorer: EvalScorer::ExactMatch,
897 latency_ms: 50.0,
898 }],
899 },
900 ];
901 let verdict = compute_verdict("test", &results);
902 assert_eq!(verdict.winner.as_deref(), Some("b"));
904 }
905
906 #[test]
907 fn verdict_full_tie_is_none() {
908 let results = vec![
909 EvalRunResult {
910 eval_name: "e".to_string(),
911 model: "m".to_string(),
912 variant: "a".to_string(),
913 prompt_version: String::new(),
914 cases: vec![EvalCaseResult {
915 case_index: 0,
916 input: "i".to_string(),
917 expected: "e".to_string(),
918 actual: "e".to_string(),
919 passed: true,
920 scorer: EvalScorer::ExactMatch,
921 latency_ms: 50.0,
922 }],
923 },
924 EvalRunResult {
925 eval_name: "e".to_string(),
926 model: "m".to_string(),
927 variant: "b".to_string(),
928 prompt_version: String::new(),
929 cases: vec![EvalCaseResult {
930 case_index: 0,
931 input: "i".to_string(),
932 expected: "e".to_string(),
933 actual: "e".to_string(),
934 passed: true,
935 scorer: EvalScorer::ExactMatch,
936 latency_ms: 50.0,
937 }],
938 },
939 ];
940 let verdict = compute_verdict("test", &results);
941 assert_eq!(verdict.winner, None);
942 }
943
944 #[test]
945 fn active_prompt_system_with_override() {
946 let experiments = AiExperiments {
947 prompts: {
948 let mut m = BTreeMap::new();
949 m.insert(
950 "greeting".to_string(),
951 AiPromptVersions {
952 versions: {
953 let mut v = BTreeMap::new();
954 v.insert(
955 "v1".to_string(),
956 AiPromptVersion {
957 system: "default system".to_string(),
958 },
959 );
960 v.insert(
961 "v2".to_string(),
962 AiPromptVersion {
963 system: "overridden system".to_string(),
964 },
965 );
966 v
967 },
968 active: "v1".to_string(),
969 },
970 );
971 m
972 },
973 ..Default::default()
974 };
975 assert_eq!(
977 active_prompt_system(Some(&experiments), &[], "greeting"),
978 Some("default system".to_string())
979 );
980 let overrides = vec![("greeting".to_string(), "v2".to_string())];
982 assert_eq!(
983 active_prompt_system(Some(&experiments), &overrides, "greeting"),
984 Some("overridden system".to_string())
985 );
986 }
987}