lean_ctx/core/value_gate/
store.rs1use 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 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}