Skip to main content

klieo_eval/
agent_eval.rs

1//! Replay-first agent regression eval (ADR-047).
2//!
3//! A golden fixture is an ADR-046 [`Capture`]. [`eval_capture_determinism`]
4//! replays it
5//! through the *current* agent code via the klieo-runlog divergence engine —
6//! the LLM and tool doubles are scripted from the recording, so a divergence
7//! means the current code made a different decision given the same recorded
8//! responses, i.e. a **code-/orchestration-behavior** regression. This is
9//! deterministic and CI-stable.
10//!
11//! It does NOT measure model-quality drift (the LLM is scripted, not live), and
12//! recorded latency / token cost are properties of the recording, not the
13//! replay — they are reported but are not regression signals until a baseline is
14//! re-recorded (live mode, deferred per ADR-047).
15
16use std::sync::Arc;
17
18use klieo_runlog::replay::{scripted_llm_from_runlog, scripted_tools_from_runlog};
19use klieo_runlog::{replay_with_divergence, Capture, DivergenceReport, ReproVerdict, RunLogError};
20
21/// Deterministic metrics for one replayed capture.
22///
23/// `reproduced`, `exact_match`, and `mean_similarity` are the **gated**
24/// regression signals (see [`check_regression`]). `recorded_latency_ms` and
25/// `recorded_total_tokens` come from the recorded run, not the replay, so they
26/// are informational only — they move when a baseline is re-recorded, not when
27/// code changes (ADR-047).
28#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29#[non_exhaustive]
30pub struct EvalMetrics {
31    /// Whole run reproduced byte-identically (`ReproVerdict::Identical`).
32    pub reproduced: bool,
33    /// Mean per-step divergence similarity; `1.0` when nothing diverged.
34    pub mean_similarity: f64,
35    /// The final recorded step reproduced (its output did not diverge) — the
36    /// "did we still arrive at the same answer" signal, distinct from
37    /// `reproduced` which requires every intermediate step to match.
38    pub exact_match: bool,
39    /// Summed step latency of the *recorded* run, in milliseconds (informational).
40    pub recorded_latency_ms: u64,
41    /// Prompt + completion tokens of the *recorded* run (informational).
42    pub recorded_total_tokens: u32,
43}
44
45/// Replays `capture` against the *recorded* LLM/tool scripts and returns its
46/// [`EvalMetrics`]. This measures **replay determinism only** — the scripted
47/// doubles feed back the recorded bytes, so a faithful replay always reproduces.
48/// It does NOT exercise the current agent logic against fresh model output; for
49/// that (regression detection on a live re-drive) use [`eval_capture_live`].
50/// Errors if the replay engine fails.
51///
52/// [`eval_capture_live`]: crate::eval_capture_live
53pub async fn eval_capture_determinism(capture: &Capture) -> Result<EvalMetrics, RunLogError> {
54    let log = &capture.run_log;
55    let llm = Arc::new(scripted_llm_from_runlog("klieo-eval", log));
56    let tools = Arc::new(scripted_tools_from_runlog(log));
57    let report = replay_with_divergence(log, llm, tools, scripted_ctx()).await?;
58    Ok(metrics_from(capture, &report))
59}
60
61/// Deprecated alias for [`eval_capture_determinism`].
62#[deprecated(since = "2.3.0", note = "renamed to eval_capture_determinism")]
63pub async fn eval_capture(capture: &Capture) -> Result<EvalMetrics, RunLogError> {
64    eval_capture_determinism(capture).await
65}
66
67fn metrics_from(capture: &Capture, report: &DivergenceReport) -> EvalMetrics {
68    let reproduced = report.verdict == ReproVerdict::Identical;
69    let mean_similarity = if report.divergences.is_empty() {
70        1.0
71    } else {
72        let sum: f64 = report.divergences.iter().map(|d| d.similarity).sum();
73        sum / report.divergences.len() as f64
74    };
75    let steps = &capture.run_log.steps;
76    let exact_match = match steps.len() {
77        0 => true,
78        n => {
79            let last = (n - 1) as u32;
80            report.divergences.iter().all(|d| d.step_index != last)
81        }
82    };
83    let recorded_latency_ms = steps.iter().map(|s| s.latency.as_millis() as u64).sum();
84    let recorded_total_tokens =
85        capture.run_log.tokens.prompt_tokens + capture.run_log.tokens.completion_tokens;
86    EvalMetrics {
87        reproduced,
88        mean_similarity,
89        exact_match,
90        recorded_latency_ms,
91        recorded_total_tokens,
92    }
93}
94
95/// One gated metric that moved the wrong way against the baseline.
96#[derive(Debug, Clone, PartialEq)]
97#[non_exhaustive]
98pub struct Regression {
99    /// Which gated metric regressed (`reproduced` / `exact_match` / `mean_similarity`).
100    pub metric: &'static str,
101    /// Human-readable baseline→current movement for the failure message.
102    pub detail: String,
103}
104
105/// Compare `current` against `baseline`, returning a [`Regression`] per gated
106/// metric that degraded. `reproduced` and `exact_match` regress on a `true →
107/// false` flip; `mean_similarity` regresses when it drops by more than
108/// `similarity_tolerance`. Latency and token cost are informational and never
109/// gated here (ADR-047).
110pub fn check_regression(
111    baseline: &EvalMetrics,
112    current: &EvalMetrics,
113    similarity_tolerance: f64,
114) -> Vec<Regression> {
115    let mut regressions = Vec::new();
116    if baseline.reproduced && !current.reproduced {
117        regressions.push(Regression {
118            metric: "reproduced",
119            detail: "baseline reproduced the run; current diverged".into(),
120        });
121    }
122    if baseline.exact_match && !current.exact_match {
123        regressions.push(Regression {
124            metric: "exact_match",
125            detail: "baseline matched the final output; current did not".into(),
126        });
127    }
128    if baseline.mean_similarity - current.mean_similarity > similarity_tolerance {
129        regressions.push(Regression {
130            metric: "mean_similarity",
131            detail: format!(
132                "similarity {:.3} -> {:.3} exceeds tolerance {:.3}",
133                baseline.mean_similarity, current.mean_similarity, similarity_tolerance
134            ),
135        });
136    }
137    regressions
138}
139
140/// In-process bus `ToolCtx` for replay; the tool doubles ignore it, but the
141/// divergence engine requires a context, and the harness must not bind a
142/// concrete bus into the eval crate's public API.
143fn scripted_ctx() -> klieo_core::tool::ToolCtx {
144    use klieo_bus_memory::{MemoryJobQueue, MemoryKv, MemoryPubsub};
145    let pubsub = Arc::new(MemoryPubsub::default());
146    let kv = Arc::new(MemoryKv::default());
147    let jobs = Arc::new(MemoryJobQueue::new(pubsub.clone(), kv.clone()));
148    klieo_core::tool::ToolCtx::new(pubsub, kv, jobs)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn metrics(reproduced: bool, mean_similarity: f64, exact_match: bool) -> EvalMetrics {
156        EvalMetrics {
157            reproduced,
158            mean_similarity,
159            exact_match,
160            recorded_latency_ms: 10,
161            recorded_total_tokens: 100,
162        }
163    }
164
165    #[test]
166    fn identical_baseline_and_current_is_clean() {
167        let b = metrics(true, 1.0, true);
168        assert!(check_regression(&b, &b.clone(), 0.05).is_empty());
169    }
170
171    #[test]
172    fn reproduced_true_to_false_regresses() {
173        let r = check_regression(&metrics(true, 1.0, true), &metrics(false, 1.0, true), 0.05);
174        assert!(r.iter().any(|x| x.metric == "reproduced"));
175    }
176
177    #[test]
178    fn exact_match_true_to_false_regresses() {
179        let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 1.0, false), 0.05);
180        assert!(r.iter().any(|x| x.metric == "exact_match"));
181    }
182
183    #[test]
184    fn similarity_drop_past_tolerance_regresses() {
185        let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 0.80, true), 0.05);
186        assert!(r.iter().any(|x| x.metric == "mean_similarity"));
187    }
188
189    #[test]
190    fn similarity_drop_within_tolerance_is_clean() {
191        let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 0.98, true), 0.05);
192        assert!(r.is_empty());
193    }
194
195    #[test]
196    fn latency_and_token_changes_are_not_gated() {
197        let b = metrics(true, 1.0, true);
198        let mut c = metrics(true, 1.0, true);
199        c.recorded_latency_ms = 9_999;
200        c.recorded_total_tokens = 9_999;
201        assert!(
202            check_regression(&b, &c, 0.05).is_empty(),
203            "recorded latency/tokens are informational, never gated"
204        );
205    }
206
207    #[test]
208    fn improvements_do_not_regress() {
209        // false -> true and a similarity rise must not flag.
210        let r = check_regression(&metrics(false, 0.7, false), &metrics(true, 1.0, true), 0.05);
211        assert!(r.is_empty());
212    }
213}