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