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