car-memgine 0.15.0

Memgine — graph-based memory engine for Common Agent Runtime
Documentation
//! Trajectory storage — persists raw (proposal, trace, outcome) tuples for
//! offline analysis, replay, and future RL-style learning.
//!
//! Stored as append-only JSONL files in a configurable directory.

use crate::distill::TraceEvent;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// A complete execution trajectory: what was attempted, what happened, and the outcome.
#[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>,
    /// Duration of the entire proposal execution in milliseconds.
    pub duration_ms: f64,
    /// Number of replan attempts (0 = first attempt succeeded or failed).
    pub replan_attempts: u32,
}

/// High-level outcome of a trajectory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrajectoryOutcome {
    /// All actions succeeded.
    Success,
    /// One or more actions failed, proposal aborted.
    Failed,
    /// Proposal was replanned and eventually succeeded.
    ReplanSuccess,
    /// Proposal was replanned but all attempts failed.
    ReplanExhausted,
}

/// Append-only trajectory store backed by JSONL files.
pub struct TrajectoryStore {
    dir: PathBuf,
}

impl TrajectoryStore {
    pub fn new(dir: &Path) -> Self {
        Self {
            dir: dir.to_path_buf(),
        }
    }

    /// Default store at ~/.car/trajectories/
    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"))
    }

    /// Append a trajectory to the store. Creates the directory if needed.
    pub fn append(&self, trajectory: &Trajectory) -> Result<(), std::io::Error> {
        use std::io::Write;
        std::fs::create_dir_all(&self.dir)?;

        // File per day: trajectories-2026-03-24.jsonl
        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(())
    }

    /// Load all trajectories from a specific date file.
    pub fn load_day(&self, date: &str) -> Vec<Trajectory> {
        let path = self.dir.join(format!("trajectories-{}.jsonl", date));
        self.load_file(&path)
    }

    /// Load all trajectories from all files in the store.
    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
    }

    /// Compute summary stats from all stored trajectories.
    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
            },
        }
    }
}

/// Summary statistics across all stored trajectories.
#[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);
    }
}