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