use std::sync::Arc;
use klieo_runlog::replay::{scripted_llm_from_runlog, scripted_tools_from_runlog};
use klieo_runlog::{replay_with_divergence, Capture, DivergenceReport, ReproVerdict, RunLogError};
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct EvalMetrics {
pub reproduced: bool,
pub mean_similarity: f64,
pub exact_match: bool,
pub recorded_latency_ms: u64,
pub recorded_total_tokens: u32,
}
pub async fn eval_capture_determinism(capture: &Capture) -> Result<EvalMetrics, RunLogError> {
let log = &capture.run_log;
let llm = Arc::new(scripted_llm_from_runlog("klieo-eval", log));
let tools = Arc::new(scripted_tools_from_runlog(log));
let report = replay_with_divergence(log, llm, tools, scripted_ctx()).await?;
Ok(metrics_from(capture, &report))
}
#[deprecated(since = "2.3.0", note = "renamed to eval_capture_determinism")]
pub async fn eval_capture(capture: &Capture) -> Result<EvalMetrics, RunLogError> {
eval_capture_determinism(capture).await
}
fn metrics_from(capture: &Capture, report: &DivergenceReport) -> EvalMetrics {
let reproduced = report.verdict == ReproVerdict::Identical;
let mean_similarity = if report.divergences.is_empty() {
1.0
} else {
let sum: f64 = report.divergences.iter().map(|d| d.similarity).sum();
sum / report.divergences.len() as f64
};
let steps = &capture.run_log.steps;
let exact_match = match steps.len() {
0 => true,
n => {
let last = (n - 1) as u32;
report.divergences.iter().all(|d| d.step_index != last)
}
};
let recorded_latency_ms = steps.iter().map(|s| s.latency.as_millis() as u64).sum();
let recorded_total_tokens =
capture.run_log.tokens.prompt_tokens + capture.run_log.tokens.completion_tokens;
EvalMetrics {
reproduced,
mean_similarity,
exact_match,
recorded_latency_ms,
recorded_total_tokens,
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Regression {
pub metric: &'static str,
pub detail: String,
}
pub fn check_regression(
baseline: &EvalMetrics,
current: &EvalMetrics,
similarity_tolerance: f64,
) -> Vec<Regression> {
let mut regressions = Vec::new();
if baseline.reproduced && !current.reproduced {
regressions.push(Regression {
metric: "reproduced",
detail: "baseline reproduced the run; current diverged".into(),
});
}
if baseline.exact_match && !current.exact_match {
regressions.push(Regression {
metric: "exact_match",
detail: "baseline matched the final output; current did not".into(),
});
}
if baseline.mean_similarity - current.mean_similarity > similarity_tolerance {
regressions.push(Regression {
metric: "mean_similarity",
detail: format!(
"similarity {:.3} -> {:.3} exceeds tolerance {:.3}",
baseline.mean_similarity, current.mean_similarity, similarity_tolerance
),
});
}
regressions
}
fn scripted_ctx() -> klieo_core::tool::ToolCtx {
use klieo_bus_memory::{MemoryJobQueue, MemoryKv, MemoryPubsub};
let pubsub = Arc::new(MemoryPubsub::default());
let kv = Arc::new(MemoryKv::default());
let jobs = Arc::new(MemoryJobQueue::new(pubsub.clone(), kv.clone()));
klieo_core::tool::ToolCtx::new(pubsub, kv, jobs)
}
#[cfg(test)]
mod tests {
use super::*;
fn metrics(reproduced: bool, mean_similarity: f64, exact_match: bool) -> EvalMetrics {
EvalMetrics {
reproduced,
mean_similarity,
exact_match,
recorded_latency_ms: 10,
recorded_total_tokens: 100,
}
}
#[test]
fn identical_baseline_and_current_is_clean() {
let b = metrics(true, 1.0, true);
assert!(check_regression(&b, &b.clone(), 0.05).is_empty());
}
#[test]
fn reproduced_true_to_false_regresses() {
let r = check_regression(&metrics(true, 1.0, true), &metrics(false, 1.0, true), 0.05);
assert!(r.iter().any(|x| x.metric == "reproduced"));
}
#[test]
fn exact_match_true_to_false_regresses() {
let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 1.0, false), 0.05);
assert!(r.iter().any(|x| x.metric == "exact_match"));
}
#[test]
fn similarity_drop_past_tolerance_regresses() {
let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 0.80, true), 0.05);
assert!(r.iter().any(|x| x.metric == "mean_similarity"));
}
#[test]
fn similarity_drop_within_tolerance_is_clean() {
let r = check_regression(&metrics(true, 1.0, true), &metrics(true, 0.98, true), 0.05);
assert!(r.is_empty());
}
#[test]
fn latency_and_token_changes_are_not_gated() {
let b = metrics(true, 1.0, true);
let mut c = metrics(true, 1.0, true);
c.recorded_latency_ms = 9_999;
c.recorded_total_tokens = 9_999;
assert!(
check_regression(&b, &c, 0.05).is_empty(),
"recorded latency/tokens are informational, never gated"
);
}
#[test]
fn improvements_do_not_regress() {
let r = check_regression(&metrics(false, 0.7, false), &metrics(true, 1.0, true), 0.05);
assert!(r.is_empty());
}
}