use crate::distill::TraceEvent;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trajectory {
pub proposal_id: String,
pub source: String,
pub action_count: usize,
pub events: Vec<TraceEvent>,
pub outcome: TrajectoryOutcome,
pub timestamp: DateTime<Utc>,
pub duration_ms: f64,
pub replan_attempts: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrajectoryOutcome {
Success,
Failed,
ReplanSuccess,
ReplanExhausted,
}
pub struct TrajectoryStore {
dir: PathBuf,
}
impl TrajectoryStore {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
}
}
pub fn default_path() -> PathBuf {
std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".car").join("trajectories"))
.unwrap_or_else(|_| PathBuf::from("/tmp/car-trajectories"))
}
pub fn append(&self, trajectory: &Trajectory) -> Result<(), std::io::Error> {
use std::io::Write;
std::fs::create_dir_all(&self.dir)?;
let date = trajectory.timestamp.format("%Y-%m-%d");
let path = self.dir.join(format!("trajectories-{}.jsonl", date));
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
let json = serde_json::to_string(trajectory)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
writeln!(file, "{}", json)?;
Ok(())
}
pub fn load_day(&self, date: &str) -> Vec<Trajectory> {
let path = self.dir.join(format!("trajectories-{}.jsonl", date));
self.load_file(&path)
}
pub fn load_all(&self) -> Vec<Trajectory> {
let mut all = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut paths: Vec<PathBuf> = entries
.flatten()
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "jsonl")
&& e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("trajectories-"))
})
.map(|e| e.path())
.collect();
paths.sort();
for path in paths {
all.extend(self.load_file(&path));
}
}
all
}
fn load_file(&self, path: &Path) -> Vec<Trajectory> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut results = Vec::new();
for (i, line) in content.lines().enumerate() {
match serde_json::from_str::<Trajectory>(line) {
Ok(t) => results.push(t),
Err(e) => {
tracing::warn!(
path = %path.display(),
line = i + 1,
error = %e,
"corrupt trajectory entry"
);
}
}
}
results
}
pub fn stats(&self) -> TrajectoryStats {
let all = self.load_all();
let total = all.len();
let successes = all
.iter()
.filter(|t| t.outcome == TrajectoryOutcome::Success)
.count();
let replan_successes = all
.iter()
.filter(|t| t.outcome == TrajectoryOutcome::ReplanSuccess)
.count();
let failures = all
.iter()
.filter(|t| {
t.outcome == TrajectoryOutcome::Failed
|| t.outcome == TrajectoryOutcome::ReplanExhausted
})
.count();
let avg_duration = if total > 0 {
all.iter().map(|t| t.duration_ms).sum::<f64>() / total as f64
} else {
0.0
};
let avg_actions = if total > 0 {
all.iter().map(|t| t.action_count).sum::<usize>() as f64 / total as f64
} else {
0.0
};
TrajectoryStats {
total,
successes,
replan_successes,
failures,
avg_duration_ms: avg_duration,
avg_actions,
success_rate: if total > 0 {
(successes + replan_successes) as f64 / total as f64
} else {
0.0
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrajectoryStats {
pub total: usize,
pub successes: usize,
pub replan_successes: usize,
pub failures: usize,
pub avg_duration_ms: f64,
pub avg_actions: f64,
pub success_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_trajectory(outcome: TrajectoryOutcome) -> Trajectory {
Trajectory {
proposal_id: "test".to_string(),
source: "test".to_string(),
action_count: 3,
events: vec![TraceEvent {
kind: "action_succeeded".to_string(),
action_id: Some("a1".to_string()),
tool: Some("search".to_string()),
data: serde_json::json!({}),
..Default::default()
}],
outcome,
timestamp: Utc::now(),
duration_ms: 150.0,
replan_attempts: 0,
}
}
#[test]
fn append_and_load() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
let t = make_trajectory(TrajectoryOutcome::Success);
store.append(&t).unwrap();
store
.append(&make_trajectory(TrajectoryOutcome::Failed))
.unwrap();
let all = store.load_all();
assert_eq!(all.len(), 2);
assert_eq!(all[0].outcome, TrajectoryOutcome::Success);
assert_eq!(all[1].outcome, TrajectoryOutcome::Failed);
}
#[test]
fn stats_computation() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
store
.append(&make_trajectory(TrajectoryOutcome::Success))
.unwrap();
store
.append(&make_trajectory(TrajectoryOutcome::Success))
.unwrap();
store
.append(&make_trajectory(TrajectoryOutcome::Failed))
.unwrap();
store
.append(&make_trajectory(TrajectoryOutcome::ReplanSuccess))
.unwrap();
let stats = store.stats();
assert_eq!(stats.total, 4);
assert_eq!(stats.successes, 2);
assert_eq!(stats.replan_successes, 1);
assert_eq!(stats.failures, 1);
assert!((stats.success_rate - 0.75).abs() < 0.01);
}
#[test]
fn empty_store_stats() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
let stats = store.stats();
assert_eq!(stats.total, 0);
assert_eq!(stats.success_rate, 0.0);
}
#[test]
fn trajectory_serialization_roundtrip() {
let t = make_trajectory(TrajectoryOutcome::ReplanExhausted);
let json = serde_json::to_string(&t).unwrap();
let parsed: Trajectory = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.outcome, TrajectoryOutcome::ReplanExhausted);
assert_eq!(parsed.action_count, 3);
}
}