Skip to main content

lean_ctx/core/value_gate/
store.rs

1use super::{ValueAssessment, cpao};
2use std::{
3    collections::VecDeque,
4    fs::{self, OpenOptions},
5    io::{self, Write},
6    path::{Path, PathBuf},
7    sync::{Arc, Mutex, PoisonError},
8};
9
10const MAX_ASSESSMENTS: usize = 100;
11const MAX_DISK_ASSESSMENTS: usize = 10_000;
12const MAX_LEDGER_BYTES: u64 = 1024 * 1024;
13const LEDGER_FILE: &str = "value_assessments.jsonl";
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct ValueAggregate {
17    pub total: usize,
18    pub accepted: usize,
19    pub rejected: usize,
20    pub avg_cpao: Option<u64>,
21    pub total_cost: u64,
22}
23
24#[derive(Debug, Clone)]
25pub struct ValueGateStore {
26    assessments: Arc<Mutex<VecDeque<ValueAssessment>>>,
27}
28
29impl Default for ValueGateStore {
30    fn default() -> Self {
31        let mut assessments: VecDeque<_> = Self::load_from_disk().into_iter().collect();
32        while assessments.len() > MAX_ASSESSMENTS {
33            assessments.pop_front();
34        }
35        Self {
36            assessments: Arc::new(Mutex::new(assessments)),
37        }
38    }
39}
40
41impl ValueGateStore {
42    pub fn record(&self, assessment: &ValueAssessment) {
43        let mut entries = self
44            .assessments
45            .lock()
46            .unwrap_or_else(PoisonError::into_inner);
47        if entries.len() >= MAX_ASSESSMENTS {
48            entries.pop_front();
49        }
50        entries.push_back(assessment.clone());
51        drop(entries);
52        let _ = Self::append_to_disk(assessment);
53    }
54
55    pub fn recent(&self, n: usize) -> Vec<ValueAssessment> {
56        self.assessments
57            .lock()
58            .unwrap_or_else(PoisonError::into_inner)
59            .iter()
60            .rev()
61            .take(n)
62            .cloned()
63            .collect()
64    }
65
66    pub fn aggregate(&self) -> ValueAggregate {
67        let entries = self
68            .assessments
69            .lock()
70            .unwrap_or_else(PoisonError::into_inner);
71        let costs: Vec<u64> = entries.iter().map(|a| a.cost_micros).collect();
72        let accepted: Vec<bool> = entries.iter().map(|a| a.outcome_accepted).collect();
73        let accepted_count = accepted.iter().filter(|&&ok| ok).count();
74        ValueAggregate {
75            total: entries.len(),
76            accepted: accepted_count,
77            rejected: entries.len() - accepted_count,
78            avg_cpao: cpao::cost_per_accepted_outcome(&costs, &accepted),
79            total_cost: costs.into_iter().fold(0, u64::saturating_add),
80        }
81    }
82
83    pub fn persist_path() -> PathBuf {
84        crate::core::paths::state_dir()
85            .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
86            .join(LEDGER_FILE)
87    }
88
89    pub fn append_to_disk(assessment: &ValueAssessment) -> io::Result<()> {
90        Self::append_to_path(&Self::persist_path(), assessment)
91    }
92
93    pub fn load_from_disk() -> Vec<ValueAssessment> {
94        Self::load_from_path(&Self::persist_path())
95    }
96
97    pub fn append_to_path(path: &Path, assessment: &ValueAssessment) -> io::Result<()> {
98        let json = serde_json::to_string(assessment)
99            .map_err(|error| io::Error::other(format!("serialize assessment: {error}")))?;
100        fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?;
101        Self::rotate_if_needed(path, json.len() as u64 + 1)?;
102        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
103        // One write of the whole line: with O_APPEND, concurrent writers can
104        // then never interleave inside a record, as `writeln!` might.
105        file.write_all(format!("{json}\n").as_bytes())
106    }
107
108    pub fn load_from_path(path: &Path) -> Vec<ValueAssessment> {
109        let mut assessments = Self::read_tail(&path.with_extension("jsonl.1"));
110        assessments.extend(Self::read_tail(path));
111        let keep_from = assessments.len().saturating_sub(MAX_DISK_ASSESSMENTS);
112        assessments.drain(..keep_from);
113        assessments
114    }
115
116    fn read_tail(path: &Path) -> Vec<ValueAssessment> {
117        let mut assessments: Vec<_> = fs::read_to_string(path)
118            .unwrap_or_default()
119            .lines()
120            .rev()
121            .take(MAX_DISK_ASSESSMENTS)
122            .filter_map(|line| serde_json::from_str(line).ok())
123            .collect();
124        assessments.reverse();
125        assessments
126    }
127
128    fn rotate_if_needed(path: &Path, incoming_bytes: u64) -> io::Result<()> {
129        if fs::metadata(path)
130            .is_ok_and(|meta| meta.len().saturating_add(incoming_bytes) > MAX_LEDGER_BYTES)
131        {
132            let backup = path.with_extension("jsonl.1");
133            if backup.exists() {
134                fs::remove_file(&backup)?;
135            }
136            fs::rename(path, backup)?;
137        }
138        Ok(())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn assessment(id: usize) -> ValueAssessment {
147        ValueAssessment {
148            task_id: format!("task-{id}"),
149            model: "test".into(),
150            total_tokens: id as u64,
151            cost_micros: 10,
152            outcome_accepted: true,
153            cpao_micros: Some(10),
154            evidence: vec!["test".into()],
155            timestamp: "2026-08-12T00:00:00Z".into(),
156        }
157    }
158
159    fn test_path(name: &str) -> PathBuf {
160        let path =
161            std::env::temp_dir().join(format!("lean-ctx-value-gate-{name}-{}", std::process::id()));
162        let _ = fs::remove_dir_all(&path);
163        path.join(LEDGER_FILE)
164    }
165
166    #[test]
167    fn test_persist_creates_file() {
168        let _iso = crate::core::data_dir::isolated_data_dir();
169        let store = ValueGateStore::default();
170        let path = ValueGateStore::persist_path();
171        store.record(&assessment(1));
172        assert!(path.is_file());
173    }
174
175    #[test]
176    fn test_persist_append() {
177        let path = test_path("append");
178        (0..3).for_each(|id| ValueGateStore::append_to_path(&path, &assessment(id)).unwrap());
179        assert_eq!(fs::read_to_string(path).unwrap().lines().count(), 3);
180    }
181
182    #[test]
183    fn test_load_roundtrip() {
184        let path = test_path("roundtrip");
185        let expected = vec![assessment(1), assessment(2)];
186        for item in &expected {
187            ValueGateStore::append_to_path(&path, item).unwrap();
188        }
189        assert_eq!(ValueGateStore::load_from_path(&path), expected);
190    }
191
192    #[test]
193    fn test_load_corrupt_line() {
194        let path = test_path("corrupt");
195        ValueGateStore::append_to_path(&path, &assessment(1)).unwrap();
196        fs::write(
197            &path,
198            format!(
199                "{{not json}}\n{}",
200                serde_json::to_string(&assessment(2)).unwrap()
201            ),
202        )
203        .unwrap();
204        assert_eq!(ValueGateStore::load_from_path(&path), vec![assessment(2)]);
205    }
206
207    #[test]
208    fn test_load_empty_file() {
209        let path = test_path("empty");
210        fs::create_dir_all(path.parent().unwrap()).unwrap();
211        fs::write(&path, "").unwrap();
212        {
213            let found = &ValueGateStore::load_from_path(&path);
214            assert!(found.is_empty(), "{found:?}");
215        }
216    }
217}