lean_ctx/core/
feedback.rs1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::Instant;
4
5use serde::{Deserialize, Serialize};
6
7const FEEDBACK_FLUSH_SECS: u64 = 60;
8
9static FEEDBACK_BUFFER: Mutex<Option<(FeedbackStore, Instant)>> = Mutex::new(None);
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct CompressionOutcome {
18 pub session_id: String,
19 pub language: String,
20 pub entropy_threshold: f64,
21 pub jaccard_threshold: f64,
22 pub total_turns: u32,
23 pub tokens_saved: u64,
24 pub tokens_original: u64,
25 pub cache_hits: u32,
26 pub total_reads: u32,
27 pub task_completed: bool,
28 pub timestamp: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct FeedbackStore {
33 pub outcomes: Vec<CompressionOutcome>,
34 pub learned_thresholds: HashMap<String, LearnedThresholds>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct LearnedThresholds {
39 pub entropy: f64,
40 pub jaccard: f64,
41 pub sample_count: u32,
42 pub avg_efficiency: f64,
43}
44
45impl FeedbackStore {
46 pub fn load() -> Self {
47 let guard = FEEDBACK_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
48 if let Some((ref store, _)) = *guard {
49 return store.clone();
50 }
51 drop(guard);
52
53 let path = feedback_path();
54 if path.exists() {
55 if let Ok(content) = std::fs::read_to_string(&path) {
56 if let Ok(store) = serde_json::from_str::<FeedbackStore>(&content) {
57 return store;
58 }
59 }
60 }
61 Self::default()
62 }
63
64 fn save_to_disk(&self) {
65 let path = feedback_path();
66 if let Some(parent) = path.parent() {
67 let _ = std::fs::create_dir_all(parent);
68 }
69 if let Ok(json) = serde_json::to_string_pretty(self) {
70 let _ = std::fs::write(path, json);
71 }
72 }
73
74 pub fn save(&self) {
75 self.save_to_disk();
76 }
77
78 pub fn flush() {
79 let guard = FEEDBACK_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
80 if let Some((ref store, _)) = *guard {
81 store.save_to_disk();
82 }
83 }
84
85 pub fn record_outcome(&mut self, outcome: CompressionOutcome) {
86 let lang = outcome.language.clone();
87 self.outcomes.push(outcome);
88
89 if self.outcomes.len() > 200 {
90 self.outcomes.drain(0..self.outcomes.len() - 200);
91 }
92
93 self.update_learned_thresholds(&lang);
94
95 let mut guard = FEEDBACK_BUFFER.lock().unwrap_or_else(|e| e.into_inner());
96 let should_flush = match *guard {
97 Some((_, ref last)) => last.elapsed().as_secs() >= FEEDBACK_FLUSH_SECS,
98 None => true,
99 };
100 *guard = Some((
101 self.clone(),
102 guard.as_ref().map_or_else(Instant::now, |(_, t)| *t),
103 ));
104 if should_flush {
105 self.save_to_disk();
106 if let Some((_, ref mut t)) = *guard {
107 *t = Instant::now();
108 }
109 }
110 }
111
112 fn update_learned_thresholds(&mut self, language: &str) {
113 let relevant: Vec<&CompressionOutcome> = self
114 .outcomes
115 .iter()
116 .filter(|o| o.language == language && o.task_completed)
117 .collect();
118
119 if relevant.len() < 5 {
120 return; }
122
123 let mut best_entropy = 1.0;
126 let mut best_jaccard = 0.7;
127 let mut best_efficiency = 0.0;
128
129 for outcome in &relevant {
130 let compression_ratio = if outcome.tokens_original > 0 {
131 outcome.tokens_saved as f64 / outcome.tokens_original as f64
132 } else {
133 0.0
134 };
135 let turn_efficiency = 1.0 / (outcome.total_turns.max(1) as f64);
136 let efficiency = compression_ratio * 0.6 + turn_efficiency * 0.4;
137
138 if efficiency > best_efficiency {
139 best_efficiency = efficiency;
140 best_entropy = outcome.entropy_threshold;
141 best_jaccard = outcome.jaccard_threshold;
142 }
143 }
144
145 let entry = self
147 .learned_thresholds
148 .entry(language.to_string())
149 .or_insert(LearnedThresholds {
150 entropy: best_entropy,
151 jaccard: best_jaccard,
152 sample_count: 0,
153 avg_efficiency: 0.0,
154 });
155
156 let momentum = 0.7; entry.entropy = entry.entropy * momentum + best_entropy * (1.0 - momentum);
158 entry.jaccard = entry.jaccard * momentum + best_jaccard * (1.0 - momentum);
159 entry.sample_count = relevant.len() as u32;
160 entry.avg_efficiency = best_efficiency;
161 }
162
163 pub fn get_learned_entropy(&self, language: &str) -> Option<f64> {
164 self.learned_thresholds.get(language).map(|t| t.entropy)
165 }
166
167 pub fn get_learned_jaccard(&self, language: &str) -> Option<f64> {
168 self.learned_thresholds.get(language).map(|t| t.jaccard)
169 }
170
171 pub fn format_report(&self) -> String {
172 let mut lines = vec![String::from("Feedback Loop Report")];
173 lines.push(format!("Total outcomes tracked: {}", self.outcomes.len()));
174 lines.push(String::new());
175
176 if self.learned_thresholds.is_empty() {
177 lines.push(
178 "No learned thresholds yet (need 5+ completed sessions per language).".to_string(),
179 );
180 } else {
181 lines.push("Learned Thresholds:".to_string());
182 for (lang, t) in &self.learned_thresholds {
183 lines.push(format!(
184 " {lang}: entropy={:.2} jaccard={:.2} (n={}, eff={:.1}%)",
185 t.entropy,
186 t.jaccard,
187 t.sample_count,
188 t.avg_efficiency * 100.0
189 ));
190 }
191 }
192
193 lines.join("\n")
194 }
195}
196
197fn feedback_path() -> std::path::PathBuf {
198 dirs::home_dir()
199 .unwrap_or_else(|| std::path::PathBuf::from("."))
200 .join(".lean-ctx")
201 .join("feedback.json")
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn empty_store_loads() {
210 let store = FeedbackStore::default();
211 assert!(store.outcomes.is_empty());
212 assert!(store.learned_thresholds.is_empty());
213 }
214
215 #[test]
216 fn learned_thresholds_need_minimum_samples() {
217 let mut store = FeedbackStore::default();
218 for i in 0..3 {
219 store.record_outcome(CompressionOutcome {
220 session_id: format!("s{i}"),
221 language: "rs".to_string(),
222 entropy_threshold: 0.85,
223 jaccard_threshold: 0.72,
224 total_turns: 5,
225 tokens_saved: 1000,
226 tokens_original: 2000,
227 cache_hits: 3,
228 total_reads: 10,
229 task_completed: true,
230 timestamp: String::new(),
231 });
232 }
233 assert!(store.get_learned_entropy("rs").is_none()); }
235}