use crate::eval::{EvalConstraints, EvalMetric, EvalResult};
use crate::tools::{is_read_tool, is_write_tool};
use crate::trace::{Run, RunEvent};
pub struct TrajectoryReplay {
pub run: Run,
}
impl TrajectoryReplay {
pub fn new(run: Run) -> Self {
Self { run }
}
pub fn tool_call_counts(&self) -> Vec<(&str, usize)> {
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for event in &self.run.events {
if let RunEvent::ToolCall { name, .. } = event {
*counts.entry(name.as_str()).or_default() += 1;
}
}
let mut result: Vec<_> = counts.into_iter().collect();
result.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
result
}
pub fn total_tool_calls(&self) -> usize {
self.run
.events
.iter()
.filter(|e| matches!(e, RunEvent::ToolCall { .. }))
.count()
}
pub fn error_count(&self) -> usize {
self.run
.events
.iter()
.filter(|e| matches!(e, RunEvent::ToolError { .. } | RunEvent::Error { .. }))
.count()
}
fn normalize_path(path: &str) -> String {
let p = path.replace('\\', "/").replace("//", "/");
let parts: Vec<&str> = p.split('/').collect();
let mut out: Vec<&str> = Vec::new();
for part in parts {
match part {
"." | "" => {}
".." => {
out.pop();
}
_ => out.push(part),
}
}
out.join("/")
}
pub fn written_files(&self) -> Vec<String> {
let mut files = Vec::new();
for event in &self.run.events {
if let RunEvent::ToolCall { name, args, .. } = event
&& (is_write_tool(name) || is_read_tool(name))
&& let Some(args) = args
&& let Some(path) = args.get("path").or_else(|| args.get("file_path"))
&& let Some(s) = path.as_str()
{
files.push(Self::normalize_path(s));
}
}
files
}
pub fn detect_write_without_read(&self) -> Vec<String> {
let mut violations = Vec::new();
let mut read_files: std::collections::HashSet<String> = std::collections::HashSet::new();
for event in &self.run.events {
match event {
RunEvent::ToolCall { name, args, .. } if is_read_tool(name) => {
if let Some(args) = args
&& let Some(path) = args.get("path").or_else(|| args.get("file_path"))
&& let Some(s) = path.as_str()
{
read_files.insert(Self::normalize_path(s));
}
}
RunEvent::ToolResult { name, success, .. } if is_read_tool(name) && *success => {
}
RunEvent::ToolCall { name, args, .. } if is_write_tool(name) => {
if let Some(args) = args
&& let Some(path) = args.get("path").or_else(|| args.get("file_path"))
&& let Some(s) = path.as_str()
{
let normalized = Self::normalize_path(s);
if !read_files.contains(&normalized) {
violations.push(format!("Write without read: {name} on {normalized}"));
}
}
}
_ => {}
}
}
violations
}
pub fn evaluate_constraints(&self, constraints: &EvalConstraints) -> Vec<String> {
let mut violations = Vec::new();
if let Some(max) = constraints.max_tool_calls {
let total = self.total_tool_calls();
if total > max {
violations.push(format!("Too many tool calls: {total} (max {max})"));
}
}
if constraints.required_read_before_edit {
let rbe_violations = self.detect_write_without_read();
violations.extend(rbe_violations);
}
violations
}
pub fn to_metrics(&self, constraints: &EvalConstraints) -> Vec<EvalMetric> {
let total_calls = self.total_tool_calls();
let errors = self.error_count();
let efficiency = if total_calls > 0 {
1.0 - (errors as f64 / total_calls as f64).min(1.0)
} else {
1.0
};
let violations = self.evaluate_constraints(constraints);
let constraint_score = if violations.is_empty() {
1.0
} else {
1.0 - (violations.len() as f64 * 0.2).min(1.0)
};
vec![
EvalMetric {
name: "tool_calls".into(),
score: 1.0, detail: format!("{total_calls} total tool calls"),
},
EvalMetric {
name: "error_rate".into(),
score: efficiency,
detail: format!("{errors} errors in {total_calls} calls"),
},
EvalMetric {
name: "constraint_compliance".into(),
score: constraint_score,
detail: format!("{} violations", violations.len()),
},
]
}
pub fn evaluate(&self, case_id: &str, constraints: &EvalConstraints) -> EvalResult {
let violations = self.evaluate_constraints(constraints);
let metrics = self.to_metrics(constraints);
let success = violations.is_empty();
let score = if metrics.is_empty() {
if success { 1.0 } else { 0.0 }
} else {
metrics.iter().map(|m| m.score).sum::<f64>() / metrics.len() as f64
};
EvalResult {
case_id: case_id.to_string(),
success,
score,
metrics,
violations,
run_id: Some(self.run.run_id.clone()),
duration_ms: self.run.timings.total_duration_ms,
tool_calls: self.total_tool_calls(),
tokens_in: self.run.token_usage.prompt_tokens,
tokens_out: self.run.token_usage.completion_tokens,
file_changes: self.written_files().len(),
compile_ok: None,
tests_pass: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::{RunStatus, RunTimings, TokenUsage};
use chrono::Utc;
fn make_run(events: Vec<RunEvent>) -> Run {
Run {
run_id: "test_run".into(),
parent_run_id: None,
session_id: "test_session".into(),
status: RunStatus::Completed,
input: "test".into(),
events,
final_output: Some("ok".into()),
error: None,
token_usage: TokenUsage::default(),
timings: RunTimings::default(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
}
}
#[test]
fn test_tool_call_counts() {
let run = make_run(vec![
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "read_file".into(),
args: None,
risk: None,
duration_ms: 10,
},
RunEvent::ToolResult {
call_id: "test_call".into(),
name: "read_file".into(),
success: true,
output_preview: None,
output_truncated: false,
duration_ms: 0,
},
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "edit_file".into(),
args: None,
risk: None,
duration_ms: 20,
},
RunEvent::ToolResult {
call_id: "test_call".into(),
name: "edit_file".into(),
success: true,
output_preview: None,
output_truncated: false,
duration_ms: 0,
},
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "read_file".into(),
args: None,
risk: None,
duration_ms: 5,
},
RunEvent::ToolResult {
call_id: "test_call".into(),
name: "read_file".into(),
success: true,
output_preview: None,
output_truncated: false,
duration_ms: 0,
},
]);
let replay = TrajectoryReplay::new(run);
let counts = replay.tool_call_counts();
assert_eq!(counts.len(), 2);
assert_eq!(replay.total_tool_calls(), 3);
}
#[test]
fn test_constraint_max_tool_calls() {
let run = make_run(vec![
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "read_file".into(),
args: None,
risk: None,
duration_ms: 10,
},
RunEvent::ToolResult {
call_id: "test_call".into(),
name: "read_file".into(),
success: true,
output_preview: None,
output_truncated: false,
duration_ms: 0,
},
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "edit_file".into(),
args: None,
risk: None,
duration_ms: 20,
},
RunEvent::ToolResult {
call_id: "test_call".into(),
name: "edit_file".into(),
success: true,
output_preview: None,
output_truncated: false,
duration_ms: 0,
},
]);
let replay = TrajectoryReplay::new(run);
let constraints = EvalConstraints {
max_tool_calls: Some(1),
..Default::default()
};
let violations = replay.evaluate_constraints(&constraints);
assert!(!violations.is_empty());
}
#[test]
fn test_error_count() {
let run = make_run(vec![
RunEvent::ToolCall {
call_id: "test_call".into(),
name: "read_file".into(),
args: None,
risk: None,
duration_ms: 10,
},
RunEvent::ToolError {
call_id: "test_call".into(),
name: "read_file".into(),
message: "not found".into(),
},
]);
let replay = TrajectoryReplay::new(run);
assert_eq!(replay.error_count(), 1);
}
}