1use 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#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
29#[non_exhaustive]
30pub struct EvalMetrics {
31 pub reproduced: bool,
33 pub mean_similarity: f64,
35 pub exact_match: bool,
39 pub recorded_latency_ms: u64,
41 pub recorded_total_tokens: u32,
43}
44
45pub 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(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#[derive(Debug, Clone, PartialEq)]
97#[non_exhaustive]
98pub struct Regression {
99 pub metric: &'static str,
101 pub detail: String,
103}
104
105pub 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
140fn 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 let r = check_regression(&metrics(false, 0.7, false), &metrics(true, 1.0, true), 0.05);
211 assert!(r.is_empty());
212 }
213}