Skip to main content

lean_ctx/core/stats/
mod.rs

1mod format;
2mod io;
3mod model;
4
5pub use format::*;
6pub use model::*;
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10use std::time::Instant;
11
12/// (current_state, baseline_from_disk, last_flush_time)
13static STATS_BUFFER: Mutex<Option<(StatsStore, StatsStore, Instant)>> = Mutex::new(None);
14
15const FLUSH_INTERVAL_SECS: u64 = 30;
16
17pub fn load() -> StatsStore {
18    let guard = STATS_BUFFER
19        .lock()
20        .unwrap_or_else(std::sync::PoisonError::into_inner);
21    if let Some((ref current, ref baseline, _)) = *guard {
22        let disk = io::load_from_disk();
23        return io::apply_deltas(&disk, current, baseline);
24    }
25    drop(guard);
26    io::load_from_disk()
27}
28
29pub fn save(store: &StatsStore) {
30    io::locked_write(store);
31}
32
33fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
34    if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS {
35        let merged = io::merge_and_save(store, baseline);
36        *store = merged.clone();
37        *baseline = merged;
38        *last_flush = Instant::now();
39    }
40}
41
42pub fn flush() {
43    let mut guard = STATS_BUFFER
44        .lock()
45        .unwrap_or_else(std::sync::PoisonError::into_inner);
46    if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard {
47        let merged = io::merge_and_save(store, baseline);
48        *store = merged.clone();
49        *baseline = merged;
50        *last_flush = Instant::now();
51    }
52}
53
54pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
55    let mut guard = STATS_BUFFER
56        .lock()
57        .unwrap_or_else(std::sync::PoisonError::into_inner);
58    if guard.is_none() {
59        let disk = io::load_from_disk();
60        *guard = Some((disk.clone(), disk, Instant::now()));
61    }
62    let Some((store, baseline, last_flush)) = guard.as_mut() else {
63        return;
64    };
65
66    let is_first_command = store.total_commands == baseline.total_commands;
67    let now = chrono::Local::now();
68    let today = now.format("%Y-%m-%d").to_string();
69    let timestamp = now.to_rfc3339();
70
71    store.total_commands = store.total_commands.saturating_add(1);
72    store.total_input_tokens = store.total_input_tokens.saturating_add(input_tokens as u64);
73    store.total_output_tokens = store
74        .total_output_tokens
75        .saturating_add(output_tokens as u64);
76
77    if store.first_use.is_none() {
78        store.first_use = Some(timestamp.clone());
79    }
80    store.last_use = Some(timestamp);
81
82    let cmd_key = format::normalize_command(command);
83    let entry = store.commands.entry(cmd_key).or_default();
84    entry.count = entry.count.saturating_add(1);
85    entry.input_tokens = entry.input_tokens.saturating_add(input_tokens as u64);
86    entry.output_tokens = entry.output_tokens.saturating_add(output_tokens as u64);
87
88    if let Some(day) = store.daily.last_mut() {
89        if day.date == today {
90            day.commands = day.commands.saturating_add(1);
91            day.input_tokens = day.input_tokens.saturating_add(input_tokens as u64);
92            day.output_tokens = day.output_tokens.saturating_add(output_tokens as u64);
93        } else {
94            store.daily.push(DayStats {
95                date: today,
96                commands: 1,
97                input_tokens: input_tokens as u64,
98                output_tokens: output_tokens as u64,
99            });
100        }
101    } else {
102        store.daily.push(DayStats {
103            date: today,
104            commands: 1,
105            input_tokens: input_tokens as u64,
106            output_tokens: output_tokens as u64,
107        });
108    }
109
110    if store.daily.len() > 90 {
111        store.daily.drain(..store.daily.len() - 90);
112    }
113
114    if is_first_command {
115        let merged = io::merge_and_save(store, baseline);
116        *store = merged.clone();
117        *baseline = merged;
118        *last_flush = Instant::now();
119    } else {
120        maybe_flush(store, baseline, last_flush);
121    }
122}
123
124pub fn reset_cep() {
125    let mut guard = STATS_BUFFER
126        .lock()
127        .unwrap_or_else(std::sync::PoisonError::into_inner);
128    let mut store = io::load_from_disk();
129    store.cep = CepStats::default();
130    io::locked_write(&store);
131    *guard = Some((store.clone(), store, Instant::now()));
132}
133
134pub fn reset_all() {
135    let mut guard = STATS_BUFFER
136        .lock()
137        .unwrap_or_else(std::sync::PoisonError::into_inner);
138    let store = StatsStore::default();
139    io::locked_write(&store);
140    *guard = Some((store.clone(), store, Instant::now()));
141    crate::core::heatmap::reset();
142}
143
144pub fn load_stats() -> GainSummary {
145    let store = load();
146    let input_saved = store
147        .total_input_tokens
148        .saturating_sub(store.total_output_tokens);
149    GainSummary {
150        total_saved: input_saved,
151        total_calls: store.total_commands,
152    }
153}
154
155#[allow(clippy::too_many_arguments)]
156pub fn record_cep_session(
157    score: u32,
158    cache_hits: u64,
159    cache_reads: u64,
160    tokens_original: u64,
161    tokens_compressed: u64,
162    modes: &HashMap<String, u64>,
163    tool_calls: u64,
164    complexity: &str,
165) {
166    let mut guard = STATS_BUFFER
167        .lock()
168        .unwrap_or_else(std::sync::PoisonError::into_inner);
169    if guard.is_none() {
170        let disk = io::load_from_disk();
171        *guard = Some((disk.clone(), disk, Instant::now()));
172    }
173    let Some((store, baseline, last_flush)) = guard.as_mut() else {
174        return;
175    };
176
177    let cep = &mut store.cep;
178
179    let pid = std::process::id();
180    let prev_original = cep.last_session_original.unwrap_or(0);
181    let prev_compressed = cep.last_session_compressed.unwrap_or(0);
182    let is_same_session = cep.last_session_pid == Some(pid);
183
184    if is_same_session {
185        let delta_original = tokens_original.saturating_sub(prev_original);
186        let delta_compressed = tokens_compressed.saturating_sub(prev_compressed);
187        cep.total_tokens_original += delta_original;
188        cep.total_tokens_compressed += delta_compressed;
189    } else {
190        cep.sessions += 1;
191        cep.total_cache_hits += cache_hits;
192        cep.total_cache_reads += cache_reads;
193        cep.total_tokens_original += tokens_original;
194        cep.total_tokens_compressed += tokens_compressed;
195
196        for (mode, count) in modes {
197            *cep.modes.entry(mode.clone()).or_insert(0) += count;
198        }
199    }
200
201    cep.last_session_pid = Some(pid);
202    cep.last_session_original = Some(tokens_original);
203    cep.last_session_compressed = Some(tokens_compressed);
204
205    let cache_hit_rate = if cache_reads > 0 {
206        (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
207    } else {
208        0
209    };
210
211    let compression_rate = if tokens_original > 0 {
212        ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
213            as u32
214    } else {
215        0
216    };
217
218    let total_modes = 6u32;
219    let mode_diversity =
220        ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
221
222    let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
223
224    cep.scores.push(CepSessionSnapshot {
225        timestamp: chrono::Local::now().to_rfc3339(),
226        score,
227        cache_hit_rate,
228        mode_diversity,
229        compression_rate,
230        tool_calls,
231        tokens_saved,
232        complexity: complexity.to_string(),
233    });
234
235    if cep.scores.len() > 100 {
236        cep.scores.drain(..cep.scores.len() - 100);
237    }
238
239    maybe_flush(store, baseline, last_flush);
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
247        StatsStore {
248            total_commands: commands,
249            total_input_tokens: input,
250            total_output_tokens: output,
251            ..Default::default()
252        }
253    }
254
255    #[test]
256    fn apply_deltas_merges_mcp_and_shell() {
257        let baseline = make_store(0, 0, 0);
258        let mut current = make_store(0, 0, 0);
259        current.total_commands = 5;
260        current.total_input_tokens = 1000;
261        current.total_output_tokens = 200;
262        current.commands.insert(
263            "ctx_read".to_string(),
264            CommandStats {
265                count: 5,
266                input_tokens: 1000,
267                output_tokens: 200,
268            },
269        );
270
271        let mut disk = make_store(20, 500, 490);
272        disk.commands.insert(
273            "echo".to_string(),
274            CommandStats {
275                count: 20,
276                input_tokens: 500,
277                output_tokens: 490,
278            },
279        );
280
281        let merged = io::apply_deltas(&disk, &current, &baseline);
282
283        assert_eq!(merged.total_commands, 25);
284        assert_eq!(merged.total_input_tokens, 1500);
285        assert_eq!(merged.total_output_tokens, 690);
286        assert_eq!(merged.commands["ctx_read"].count, 5);
287        assert_eq!(merged.commands["echo"].count, 20);
288    }
289
290    #[test]
291    fn apply_deltas_incremental_flush() {
292        let baseline = make_store(10, 200, 100);
293        let current = make_store(15, 700, 300);
294
295        let disk = make_store(30, 600, 500);
296
297        let merged = io::apply_deltas(&disk, &current, &baseline);
298
299        assert_eq!(merged.total_commands, 35);
300        assert_eq!(merged.total_input_tokens, 1100);
301        assert_eq!(merged.total_output_tokens, 700);
302    }
303
304    #[test]
305    fn apply_deltas_preserves_disk_commands() {
306        let baseline = make_store(0, 0, 0);
307        let mut current = make_store(2, 100, 50);
308        current.commands.insert(
309            "ctx_read".to_string(),
310            CommandStats {
311                count: 2,
312                input_tokens: 100,
313                output_tokens: 50,
314            },
315        );
316
317        let mut disk = make_store(10, 300, 280);
318        disk.commands.insert(
319            "echo".to_string(),
320            CommandStats {
321                count: 8,
322                input_tokens: 200,
323                output_tokens: 200,
324            },
325        );
326        disk.commands.insert(
327            "ctx_read".to_string(),
328            CommandStats {
329                count: 3,
330                input_tokens: 150,
331                output_tokens: 80,
332            },
333        );
334
335        let merged = io::apply_deltas(&disk, &current, &baseline);
336
337        assert_eq!(merged.commands["echo"].count, 8);
338        assert_eq!(merged.commands["ctx_read"].count, 5);
339        assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
340    }
341
342    #[test]
343    fn merge_daily_combines_same_date() {
344        let baseline_daily = vec![];
345        let current_daily = vec![DayStats {
346            date: "2026-04-18".to_string(),
347            commands: 5,
348            input_tokens: 1000,
349            output_tokens: 200,
350        }];
351        let mut merged_daily = vec![DayStats {
352            date: "2026-04-18".to_string(),
353            commands: 20,
354            input_tokens: 500,
355            output_tokens: 490,
356        }];
357
358        io::merge_daily(&mut merged_daily, &current_daily, &baseline_daily);
359
360        assert_eq!(merged_daily.len(), 1);
361        assert_eq!(merged_daily[0].commands, 25);
362        assert_eq!(merged_daily[0].input_tokens, 1500);
363    }
364
365    #[test]
366    fn format_pct_1dp_normal() {
367        assert_eq!(format::format_pct_1dp(50.0), "50.0%");
368        assert_eq!(format::format_pct_1dp(100.0), "100.0%");
369        assert_eq!(format::format_pct_1dp(33.333), "33.3%");
370    }
371
372    #[test]
373    fn format_pct_1dp_small_values() {
374        assert_eq!(format::format_pct_1dp(0.0), "0.0%");
375        assert_eq!(format::format_pct_1dp(0.05), "<0.1%");
376        assert_eq!(format::format_pct_1dp(0.09), "<0.1%");
377        assert_eq!(format::format_pct_1dp(0.1), "0.1%");
378        assert_eq!(format::format_pct_1dp(0.5), "0.5%");
379    }
380
381    #[test]
382    fn format_savings_pct_zero_input() {
383        assert_eq!(format::format_savings_pct(0, 0), "0.0%");
384        assert_eq!(format::format_savings_pct(100, 0), "n/a");
385    }
386
387    #[test]
388    fn format_savings_pct_normal() {
389        assert_eq!(format::format_savings_pct(50, 100), "50.0%");
390        assert_eq!(format::format_savings_pct(1, 10000), "<0.1%");
391    }
392}