dwara_core/ai/
experiments.rs

1//! Prompt experimentation (DW-086): A/B model comparison, prompt
2//! versioning, regression evals, and feedback ingestion.
3//!
4//! # A/B tests
5//!
6//! A model alias declares `ab_test: <name>` to be served by an
7//! experiment. The experiment's variants each name a plain model alias
8//! (chain/canary), an optional prompt version, and a weight. The
9//! [`CompiledAbTest`] resolves each variant's model alias to its
10//! primary [`RouteTarget`] at compile time (the same composition
11//! pattern as DW-085 routing policies). At request time, a
12//! deterministic weighted pick by request id selects a variant — the
13//! same slot semantics as canary splits (ratios hold per request, and
14//! re-sending a request with the same id lands on the same variant).
15//!
16//! # Prompt versioning
17//!
18//! Each prompt declares one or more versions (each with a system
19//! message) and an active version. The active version can be
20//! overridden at runtime via the admin API (stored in the state
21//! store). When a variant references a prompt version, or when an
22//! eval references a prompt version, the system message is prepended
23//! to the request's messages (BEFORE any existing system message).
24//!
25//! # Eval runner
26//!
27//! The eval runner makes DIRECT provider calls via `hyper_util` (the
28//! same pattern as the DW-083 semantic cache and DW-085 routing
29//! policy classifier). Each golden-set case is sent as a user message
30//! to the provider; the response is scored by the case's scorer
31//! (`exact_match`, `contains`, `regex`). Results are stored in
32//! analytics. The verdict is computed from stored eval results (pass
33//! rate, cost tiebreaker).
34//!
35//! # Dependency direction
36//!
37//! `ai` depends on `config` only. The eval runner's HTTP call reuses
38//! the same `hyper_util` client pattern as the DW-083 semantic cache
39//! and DW-085 routing policy (no new dependencies).
40
41use 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/// One compiled A/B test variant (DW-086): the resolved model alias
52/// target, the optional prompt-version system message, and the
53/// variant's weight and name (for analytics attribution).
54#[derive(Debug, Clone)]
55pub struct CompiledAbVariant {
56    /// The variant name (for analytics/metrics attribution).
57    pub name: String,
58    /// The primary [`RouteTarget`] of the variant's model alias.
59    pub target: RouteTarget,
60    /// The optional prompt-version system message (prepended to the
61    /// request's messages when present).
62    pub system_message: Option<String>,
63    /// The relative weight (>= 1).
64    pub weight: u32,
65}
66
67/// A compiled A/B test (DW-086): two or more variants with resolved
68/// targets. At request time, a deterministic weighted pick by request
69/// id selects a variant. Built at [`AiRuntime`](crate::ai::AiRuntime)
70/// compile time from the config block; immutable once built.
71#[derive(Debug, Clone)]
72pub struct CompiledAbTest {
73    /// The experiment name (for analytics/metrics attribution).
74    pub name: String,
75    /// The compiled variants.
76    pub variants: Vec<CompiledAbVariant>,
77}
78
79impl CompiledAbTest {
80    /// Compile from config. Resolves each variant's model alias to
81    /// its primary [`RouteTarget`] against the first-pass compiled
82    /// model map (plain chain/canary aliases only — an experiment
83    /// alias cannot reference another experiment alias, so nested
84    /// experiments are rejected by returning None). Returns None
85    /// when a referenced alias is missing or is itself an experiment
86    /// alias (a validate-vs-build race or an authoring error
87    /// validation missed).
88    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    /// The deterministic weighted pick for `pick_key` (the request
115    /// id): the variant whose cumulative weight bound first exceeds
116    /// `pick_hash(key) % total`. Same slot semantics as canary
117    /// splits.
118    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        // weighted_pick returns a reference into the slice it was
122        // given; we pass a Vec of references, so the returned
123        // reference is to the borrowed variant. We need to map it
124        // back to the owned variant.
125        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        // Unreachable for total > 0; fallthrough keeps a hostile
136        // zero-weight list from panicking.
137        &self.variants[0]
138    }
139}
140
141/// The decision an A/B test made for one request (DW-086). The
142/// dataplane records this as a metric and an analytics assignment
143/// row. Carries the variant name and the model alias for attribution.
144#[derive(Debug, Clone)]
145pub struct ExperimentDecision {
146    /// The experiment (A/B test) name.
147    pub experiment: String,
148    /// The selected variant name.
149    pub variant: String,
150    /// The model alias the variant routes to.
151    pub model: String,
152}
153
154/// Resolve a model alias to its primary [`RouteTarget`] from the
155/// compiled model map. Returns None when the alias is missing or is
156/// itself an experiment/policy alias (no nested experiments — a
157/// variant must reference a plain chain/canary alias).
158fn 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        // Policy and Experiment aliases have no single primary
166        // target — they are evaluated per request. A variant
167        // referencing one is a nested-experiment error.
168        CompiledModel::Policy(_) => None,
169        CompiledModel::Experiment(_) => None,
170    }
171}
172
173/// Resolve a `"prompt_name/version_name"` reference to its system
174/// message. Returns None when the prompt or version does not exist
175/// (a validate-vs-build race or an authoring error validation
176/// missed — the variant serves without a system message).
177fn 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
185/// Resolve the active version for a prompt, considering runtime
186/// overrides. Returns the system message of the active (or
187/// overridden) version, or None when the prompt does not exist.
188pub 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    // Check overrides first (runtime override takes precedence).
196    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// -----------------------------------------------------------------
206// Eval runner: direct provider calls via hyper_util.
207// -----------------------------------------------------------------
208
209/// The scorer for an eval case (DW-086, AI-10/#199).
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum EvalScorer {
212    /// Exact string match between the output and `expected`.
213    ExactMatch,
214    /// The output must CONTAIN the `expected` string.
215    Contains,
216    /// The output must MATCH the `expected` regex pattern.
217    Regex,
218    /// AI-10 (#199): LLM-judge scorer. An LLM evaluates whether the
219    /// output is a good response to the input, comparing against the
220    /// `expected` value as a reference. The judge prompt is a simple
221    /// yes/no comparison. This scorer requires a provider URL and
222    /// auth to be configured at eval-run time.
223    LlmJudge,
224    /// AI-10 (#199): Semantic-similarity scorer. Uses a simple
225    /// token-overlap heuristic (Jaccard similarity) as a proxy for
226    /// semantic similarity. Passes when the Jaccard index between the
227    /// output and expected tokens exceeds 0.5. This avoids adding a
228    /// new embedding dependency; a future enhancement can plug in a
229    /// real embedding model.
230    SemanticSimilarity,
231}
232
233impl EvalScorer {
234    /// Parse a scorer name string. Defaults to `ExactMatch` for an
235    /// unrecognized or None value.
236    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    /// The stable name string (for analytics storage).
247    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    /// Score an output against an expected value. For `LlmJudge`,
258    /// this is a fallback heuristic (the real LLM judge runs
259    /// asynchronously in the eval runner); the fallback uses
260    /// `Contains` semantics. For `SemanticSimilarity`, uses Jaccard
261    /// token overlap with a 0.5 threshold.
262    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                // Fallback heuristic: the real LLM judge runs in the
271                // eval runner (async). For synchronous scoring, use
272                // contains as a simple proxy.
273                output.contains(expected)
274            }
275            EvalScorer::SemanticSimilarity => jaccard_similarity(output, expected) >= 0.5,
276        }
277    }
278}
279
280/// AI-10 (#199): Compute the Jaccard similarity (token overlap)
281/// between two strings. Returns a value between 0.0 and 1.0.
282fn 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/// One eval case result (DW-086): the output of running one golden-
297/// set case against a provider.
298#[derive(Debug, Clone)]
299pub struct EvalCaseResult {
300    /// The case index in the golden set.
301    pub case_index: usize,
302    /// The input prompt.
303    pub input: String,
304    /// The expected output.
305    pub expected: String,
306    /// The actual output from the provider.
307    pub actual: String,
308    /// Whether the case passed (scorer matched).
309    pub passed: bool,
310    /// The scorer used.
311    pub scorer: EvalScorer,
312    /// The latency of the provider call in milliseconds.
313    pub latency_ms: f64,
314}
315
316/// The result of running one eval against one model (DW-086).
317#[derive(Debug, Clone)]
318pub struct EvalRunResult {
319    /// The eval name (from config).
320    pub eval_name: String,
321    /// The model alias the eval ran against.
322    pub model: String,
323    /// The variant name (when running an A/B test's variants), or
324    /// empty.
325    pub variant: String,
326    /// The prompt version reference, or empty.
327    pub prompt_version: String,
328    /// The per-case results.
329    pub cases: Vec<EvalCaseResult>,
330}
331
332impl EvalRunResult {
333    /// The pass rate (0.0 to 1.0): the fraction of cases that passed.
334    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    /// The number of cases that passed.
343    pub fn passed_count(&self) -> usize {
344        self.cases.iter().filter(|c| c.passed).count()
345    }
346
347    /// The average latency in milliseconds.
348    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/// The verdict of an A/B test comparison (DW-086): computed from
358/// stored eval results. The variant with the higher pass rate wins;
359/// on a tie, the lower average latency wins (the cost tiebreaker —
360/// latency is a proxy for cost when the same provider serves both
361/// variants, and the actual cost is in the spend table).
362#[derive(Debug, Clone)]
363pub struct ExperimentVerdict {
364    /// The experiment (A/B test) name.
365    pub experiment: String,
366    /// The winning variant name, or None on a tie (both variants
367    /// are equally good — the operator decides).
368    pub winner: Option<String>,
369    /// The per-variant pass rates.
370    pub pass_rates: Vec<(String, f64)>,
371    /// The per-variant average latencies (ms).
372    pub avg_latencies: Vec<(String, f64)>,
373}
374
375/// Compute the verdict from a set of eval run results (DW-086). Each
376/// result is one variant's eval run. The variant with the highest
377/// pass rate wins; on a tie, the lowest average latency wins; on a
378/// full tie (same pass rate AND same latency), the verdict is a tie
379/// (winner = None).
380pub 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        // Need at least two variants to compare.
391        results.first().map(|r| r.variant.clone())
392    } else {
393        // Sort by pass rate descending, then latency ascending.
394        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            // Full tie.
410            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/// AI-10 (#199): Auto-promotion configuration. When attached to an
424/// A/B test, the eval runner can automatically promote the winning
425/// variant when the pass-rate improvement exceeds the threshold.
426#[derive(Debug, Clone)]
427pub struct AutoPromotionConfig {
428    /// The minimum pass-rate improvement (delta) required for
429    /// auto-promotion. For example, 0.05 means the winner must have
430    /// at least 5% higher pass rate than the baseline.
431    pub min_improvement: f64,
432    /// The minimum number of eval cases required before
433    /// auto-promotion is considered (statistical significance proxy).
434    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/// AI-10 (#199): The result of an auto-promotion decision.
447#[derive(Debug, Clone)]
448pub struct AutoPromotionDecision {
449    /// The experiment name.
450    pub experiment: String,
451    /// The variant to promote (if auto-promotion fired).
452    pub promote: Option<String>,
453    /// The reason for the decision.
454    pub reason: String,
455}
456
457/// AI-10 (#199): Evaluate whether the winning variant should be
458/// auto-promoted based on the eval results and the promotion config.
459/// Returns a decision describing whether to promote and why.
460pub 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    // Check minimum case count.
473    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    // Sort by pass rate descending, then latency ascending.
482    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
520/// Build a hyper_util client for the eval runner (the same pattern as
521/// the DW-083 semantic cache and DW-085 routing policy classifier).
522fn eval_http_client() -> Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>> {
523    Client::builder(TokioExecutor::new()).build_http::<Full<Bytes>>()
524}
525
526/// Run one eval case against a provider (DW-086): send the input as a
527/// user message to the provider's chat-completions endpoint and
528/// return the output text. The `provider_url` is the full URL
529/// (including scheme, host, port, and path), `auth_header` is the
530/// optional `Authorization` header value, and `provider_model` is
531/// the provider's own model identifier.
532async 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    // Extract the first choice's message content (OpenAI shape).
590    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/// Run an eval against a model alias (DW-086). The `provider_url` is
601/// the full URL, `auth_header` is the optional Authorization header,
602/// `provider_model` is the provider's own model identifier, and
603/// `system_message` is the optional prompt-version system message
604/// (from the eval's `prompt` reference, resolved by the caller).
605/// Returns the per-case results.
606#[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        // Same key -> same pick.
712        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        // Insert an experiment alias into the models map (simulating
762        // a second-pass compile that already added it).
763        // This shouldn't happen in practice (validation rejects it),
764        // but the compile must be defensive.
765        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        // Same pass rate, b is faster -> b wins.
903        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        // Without override: active is v1.
976        assert_eq!(
977            active_prompt_system(Some(&experiments), &[], "greeting"),
978            Some("default system".to_string())
979        );
980        // With override: active is v2.
981        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}