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 {
car_home::root()
.map(|root| root.join("trajectories"))
.unwrap_or_else(|| std::env::temp_dir().join("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(std::io::Error::other)?;
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
}
pub fn load_since(&self, cutoff: DateTime<Utc>) -> Vec<Trajectory> {
let cutoff_day = cutoff.format("%Y-%m-%d").to_string();
let mut all = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut paths: Vec<PathBuf> = entries
.flatten()
.filter(|e| {
let p = e.path();
if p.extension().is_none_or(|ext| ext != "jsonl") {
return false;
}
match e.file_name().to_str().and_then(|n| {
n.strip_prefix("trajectories-")
.and_then(|r| r.strip_suffix(".jsonl"))
.map(str::to_string)
}) {
Some(day) => day >= cutoff_day,
None => false,
}
})
.map(|e| e.path())
.collect();
paths.sort();
for path in paths {
all.extend(
self.load_file(&path)
.into_iter()
.filter(|t| t.timestamp >= cutoff),
);
}
}
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);
}
fn append_dated(store: &TrajectoryStore, days_ago: i64) {
let mut t = make_trajectory(TrajectoryOutcome::Success);
t.timestamp = Utc::now() - chrono::Duration::days(days_ago);
t.proposal_id = format!("d{days_ago}");
store.append(&t).unwrap();
}
#[test]
fn load_since_windows_by_day_file_and_timestamp() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
for days in [0, 5, 29, 31, 90] {
append_dated(&store, days);
}
assert_eq!(store.load_all().len(), 5);
let recent = store.load_since(Utc::now() - chrono::Duration::days(30));
let ids: Vec<&str> = recent.iter().map(|t| t.proposal_id.as_str()).collect();
assert_eq!(
ids,
vec!["d29", "d5", "d0"],
"oldest-first within the window"
);
}
#[test]
fn load_since_skips_old_day_files_by_name() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
append_dated(&store, 200);
assert_eq!(store.load_all().len(), 1);
assert!(store
.load_since(Utc::now() - chrono::Duration::days(30))
.is_empty());
}
#[test]
fn load_since_ignores_unrelated_files_in_the_directory() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(dir.path());
append_dated(&store, 1);
std::fs::write(dir.path().join("notes.txt"), "not a trajectory").unwrap();
std::fs::write(dir.path().join("other-2026-08-01.jsonl"), "{}").unwrap();
assert_eq!(
store
.load_since(Utc::now() - chrono::Duration::days(7))
.len(),
1
);
}
#[test]
fn load_since_on_an_empty_or_missing_store_is_empty() {
let dir = TempDir::new().unwrap();
let store = TrajectoryStore::new(&dir.path().join("does-not-exist"));
assert!(store
.load_since(Utc::now() - chrono::Duration::days(30))
.is_empty());
}
}