use crate::error::RunLogError;
use crate::replay::step_outcome;
use crate::types::{RunLog, StepKind};
use klieo_core::llm::LlmClient;
use klieo_core::tool::{ToolCtx, ToolInvoker};
use klieo_core::RunId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
pub(crate) fn text_similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
let left: Vec<char> = a.chars().collect();
let right: Vec<char> = b.chars().collect();
if left.len() < 2 || right.len() < 2 {
return 0.0;
}
let mut right_counts: HashMap<[char; 2], usize> = HashMap::with_capacity(right.len() - 1);
for w in right.windows(2) {
*right_counts.entry([w[0], w[1]]).or_insert(0) += 1;
}
let mut shared = 0usize;
for w in left.windows(2) {
if let Some(count) = right_counts.get_mut(&[w[0], w[1]]) {
if *count > 0 {
*count -= 1;
shared += 1;
}
}
}
(2.0 * shared as f64) / (left.len() - 1 + right.len() - 1) as f64
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ReproVerdict {
Identical,
Diverged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum ReplayMode {
#[default]
ScriptedSelfConsistency,
LiveAgentReDrive,
}
impl ReplayMode {
pub fn identical_qualifier(self) -> &'static str {
match self {
ReplayMode::ScriptedSelfConsistency => {
"self-consistency only — NOT an agent-logic reproduction"
}
ReplayMode::LiveAgentReDrive => "agent-logic re-drive reproduced the recording",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StepDivergence {
pub step_index: u32,
pub kind: StepKind,
pub expected: String,
pub actual: String,
pub similarity: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DivergenceReport {
pub run_id: RunId,
pub divergences: Vec<StepDivergence>,
pub verdict: ReproVerdict,
#[serde(default)]
pub mode: ReplayMode,
}
pub async fn replay_with_divergence(
run_log: &RunLog,
llm: Arc<dyn LlmClient>,
tools: Arc<dyn ToolInvoker>,
ctx: ToolCtx,
) -> Result<DivergenceReport, RunLogError> {
let mut divergences = Vec::new();
for step in &run_log.steps {
let Some(outcome) = step_outcome(step, &llm, &tools, &ctx).await? else {
continue;
};
if outcome.actual != outcome.expected {
divergences.push(StepDivergence {
step_index: step.idx,
kind: step.kind,
similarity: text_similarity(&outcome.expected, &outcome.actual),
expected: outcome.expected,
actual: outcome.actual,
});
}
}
let verdict = if divergences.is_empty() {
ReproVerdict::Identical
} else {
ReproVerdict::Diverged
};
Ok(DivergenceReport {
run_id: run_log.run_id,
divergences,
verdict,
mode: ReplayMode::ScriptedSelfConsistency,
})
}
#[cfg(test)]
mod similarity_tests {
use super::text_similarity;
#[test]
fn identical_strings_score_one() {
assert_eq!(
text_similarity("transfer approved", "transfer approved"),
1.0
);
}
#[test]
fn both_empty_score_one() {
assert_eq!(text_similarity("", ""), 1.0);
}
#[test]
fn disjoint_strings_score_zero() {
assert_eq!(text_similarity("abcd", "wxyz"), 0.0);
}
#[test]
fn one_empty_one_nonempty_scores_zero() {
assert_eq!(text_similarity("", "abcd"), 0.0);
}
#[test]
fn partial_overlap_scores_between_zero_and_one() {
let s = text_similarity("night", "nacht");
assert!(s > 0.0 && s < 1.0, "expected partial overlap, got {s}");
}
#[test]
fn single_char_strings_use_exact_match() {
assert_eq!(text_similarity("a", "a"), 1.0);
assert_eq!(text_similarity("a", "b"), 0.0);
}
#[test]
fn repeated_bigrams_count_with_multiplicity() {
assert_eq!(text_similarity("aaaa", "aaaa"), 1.0);
let s = text_similarity("aaaa", "aaab");
assert!(s > 0.0 && s < 1.0, "expected partial overlap, got {s}");
}
}
#[cfg(test)]
mod report_types_tests {
use super::{DivergenceReport, ReplayMode, ReproVerdict, StepDivergence};
use crate::types::StepKind;
use klieo_core::RunId;
#[test]
fn identical_report_round_trips_with_empty_divergences() {
let report = DivergenceReport {
run_id: RunId::new(),
divergences: vec![],
verdict: ReproVerdict::Identical,
mode: ReplayMode::ScriptedSelfConsistency,
};
let json = serde_json::to_string(&report).unwrap();
let back: DivergenceReport = serde_json::from_str(&json).unwrap();
assert!(back.divergences.is_empty());
assert_eq!(back.verdict, ReproVerdict::Identical);
assert_eq!(back.mode, ReplayMode::ScriptedSelfConsistency);
}
#[test]
fn diverged_report_round_trips_with_step_detail() {
let report = DivergenceReport {
run_id: RunId::new(),
divergences: vec![StepDivergence {
step_index: 2,
kind: StepKind::LlmCall,
expected: "approve".into(),
actual: "deny".into(),
similarity: 0.0,
}],
verdict: ReproVerdict::Diverged,
mode: ReplayMode::LiveAgentReDrive,
};
let json = serde_json::to_string(&report).unwrap();
let back: DivergenceReport = serde_json::from_str(&json).unwrap();
assert_eq!(back.divergences.len(), 1);
assert_eq!(back.divergences[0].step_index, 2);
assert_eq!(back.verdict, ReproVerdict::Diverged);
assert_eq!(back.mode, ReplayMode::LiveAgentReDrive);
}
#[test]
fn report_without_mode_field_deserializes_to_self_consistency() {
let legacy = format!(
r#"{{"run_id":"{}","divergences":[],"verdict":"Identical"}}"#,
RunId::new()
);
let back: DivergenceReport = serde_json::from_str(&legacy).unwrap();
assert_eq!(back.verdict, ReproVerdict::Identical);
assert_eq!(back.mode, ReplayMode::ScriptedSelfConsistency);
}
#[test]
fn default_replay_mode_is_self_consistency() {
assert_eq!(ReplayMode::default(), ReplayMode::ScriptedSelfConsistency);
}
#[test]
fn each_mode_qualifier_states_its_kind_distinctly() {
let scripted = ReplayMode::ScriptedSelfConsistency.identical_qualifier();
let live = ReplayMode::LiveAgentReDrive.identical_qualifier();
assert!(
scripted.contains("NOT an agent-logic reproduction"),
"self-consistency qualifier must disclaim reproduction: {scripted}"
);
assert!(
live.contains("reproduced"),
"re-drive qualifier must affirm reproduction: {live}"
);
assert_ne!(scripted, live, "the two modes must read differently");
}
}
#[cfg(test)]
mod divergence_walk_tests {
use super::*;
use crate::replay::{NoopToolInvoker, ScriptedLlmClient};
use crate::types::{RunLog, RunStatus, Step, Usage};
use chrono::Utc;
use std::sync::Arc;
fn step(idx: u32, kind: StepKind, output: serde_json::Value) -> Step {
Step {
idx,
kind,
name: None,
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: serde_json::Value::Null,
output,
error: None,
latency: std::time::Duration::ZERO,
span_id: None,
}
}
fn run_log(steps: Vec<Step>) -> RunLog {
let now = Utc::now();
RunLog {
run_id: RunId::new(),
agent: "t".into(),
started_at: now,
finished_at: Some(now),
status: RunStatus::Completed,
steps,
tokens: Usage::default(),
cost_estimate: None,
}
}
fn ctx() -> ToolCtx {
let bus = klieo_core::test_utils::noop_bus();
ToolCtx::new(bus.0, bus.2, bus.3)
}
#[tokio::test]
async fn identical_replay_yields_identical_verdict_no_divergences() {
let log = run_log(vec![step(0, StepKind::LlmCall, serde_json::json!("hello"))]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec!["hello".into()]));
let report = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap();
assert!(report.divergences.is_empty());
assert_eq!(report.verdict, ReproVerdict::Identical);
assert_eq!(report.mode, ReplayMode::ScriptedSelfConsistency);
}
#[tokio::test]
async fn mismatched_llm_step_records_divergence_and_diverged_verdict() {
let log = run_log(vec![step(
0,
StepKind::LlmCall,
serde_json::json!("approve"),
)]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec!["deny".into()]));
let report = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap();
assert_eq!(report.verdict, ReproVerdict::Diverged);
assert_eq!(report.divergences.len(), 1);
assert_eq!(report.divergences[0].step_index, 0);
assert_eq!(report.divergences[0].expected, "approve");
assert_eq!(report.divergences[0].actual, "deny");
assert!(report.divergences[0].similarity < 1.0);
}
#[tokio::test]
async fn walk_runs_to_completion_collecting_every_divergence() {
let log = run_log(vec![
step(0, StepKind::LlmCall, serde_json::json!("a")),
step(1, StepKind::LlmCall, serde_json::json!("b")),
]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec!["x".into(), "y".into()]));
let report = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap();
assert_eq!(report.divergences.len(), 2);
assert_eq!(report.divergences[0].step_index, 0);
assert_eq!(report.divergences[0].expected, "a");
assert_eq!(report.divergences[0].actual, "x");
assert_eq!(report.divergences[1].step_index, 1);
assert_eq!(report.divergences[1].expected, "b");
assert_eq!(report.divergences[1].actual, "y");
}
#[tokio::test]
async fn mismatched_tool_step_records_tool_divergence() {
let tool_step = Step {
idx: 0,
kind: StepKind::ToolCall,
name: Some("calc".into()),
prompt_tokens: None,
completion_tokens: None,
cost_usd: None,
input: serde_json::json!({}),
output: serde_json::json!({"y": 4}),
error: None,
latency: std::time::Duration::ZERO,
span_id: None,
};
let log = run_log(vec![tool_step]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec![]));
let tools = Arc::new(klieo_core::test_utils::FakeToolInvoker::new().with_tool(
"calc",
"calculator",
|_| Ok(serde_json::json!({"y": 99})),
));
let report = replay_with_divergence(&log, llm, tools, ctx())
.await
.unwrap();
assert_eq!(report.verdict, ReproVerdict::Diverged);
assert_eq!(report.divergences.len(), 1);
assert_eq!(report.divergences[0].kind, StepKind::ToolCall);
assert_eq!(report.divergences[0].expected, "{\"y\":4}");
assert_eq!(report.divergences[0].actual, "{\"y\":99}");
assert!(report.divergences[0].similarity < 1.0);
}
#[tokio::test]
async fn empty_runlog_yields_identical_with_no_divergences() {
let log = run_log(vec![]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec![]));
let report = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap();
assert!(report.divergences.is_empty());
assert_eq!(report.verdict, ReproVerdict::Identical);
}
#[tokio::test]
async fn double_failure_is_err_with_preserved_source() {
use std::error::Error;
let log = run_log(vec![step(0, StepKind::LlmCall, serde_json::json!("a"))]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec![]));
let err = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap_err();
assert!(matches!(
err,
crate::error::RunLogError::ReplayStep { step: 0, .. }
));
let source = err.source().expect("ReplayStep must carry a typed source");
assert!(
source.to_string().contains("exhausted"),
"source should be the script-exhausted LlmError; got: {source}"
);
}
#[tokio::test]
async fn summary_and_ops_steps_are_skipped() {
let log = run_log(vec![
step(0, StepKind::SummaryCheckpoint, serde_json::Value::Null),
step(1, StepKind::OpsEvent, serde_json::Value::Null),
step(2, StepKind::LlmCall, serde_json::json!("ok")),
]);
let llm = Arc::new(ScriptedLlmClient::new("r", vec!["ok".into()]));
let report = replay_with_divergence(&log, llm, Arc::new(NoopToolInvoker), ctx())
.await
.unwrap();
assert_eq!(report.verdict, ReproVerdict::Identical);
}
}