Skip to main content

lean_ctx/tools/
ctx_metrics.rs

1use std::collections::HashMap;
2
3use crate::core::cache::SessionCache;
4use crate::tools::{CrpMode, ToolCallRecord};
5
6pub fn handle(cache: &SessionCache, tool_calls: &[ToolCallRecord], crp_mode: CrpMode) -> String {
7    let cache_stats = cache.get_stats();
8    let refs = cache.file_ref_map();
9
10    let total_original: u64 = tool_calls.iter().map(|c| c.original_tokens as u64).sum();
11    let total_saved: u64 = tool_calls.iter().map(|c| c.saved_tokens as u64).sum();
12    let total_sent = total_original.saturating_sub(total_saved);
13    let pct = if total_original > 0 {
14        total_saved as f64 / total_original as f64 * 100.0
15    } else {
16        0.0
17    };
18
19    let mut out = Vec::new();
20    let env_model = std::env::var("LEAN_CTX_MODEL")
21        .or_else(|_| std::env::var("LCTX_MODEL"))
22        .ok();
23    let pricing = crate::core::gain::model_pricing::ModelPricing::load();
24    let quote = pricing.quote(env_model.as_deref());
25
26    if crp_mode.is_tdd() {
27        out.push("§metrics".to_string());
28        out.push("═".repeat(40));
29
30        out.push(format!(
31            "files:{} reads:{} hits:{} ({:.0}%)",
32            cache_stats.files_tracked,
33            cache_stats.total_reads,
34            cache_stats.cache_hits,
35            cache_stats.hit_rate()
36        ));
37
38        out.push(format!(
39            "tok: {}→{} | saved:{} ({:.1}%)",
40            format_tokens(total_original),
41            format_tokens(total_sent),
42            format_tokens(total_saved),
43            pct
44        ));
45
46        let cost_saved = total_saved as f64 / 1_000_000.0 * quote.cost.input_per_m;
47        let cost_without = total_original as f64 / 1_000_000.0 * quote.cost.input_per_m;
48        out.push(format!(
49            "cost: ${:.4}→${:.4} | -${:.4}",
50            cost_without,
51            cost_without - cost_saved,
52            cost_saved
53        ));
54    } else {
55        out.push("lean-ctx session metrics".to_string());
56        out.push("═".repeat(50));
57
58        out.push(format!(
59            "Files tracked: {} | Reads: {} | Cache hits: {} ({:.0}%)",
60            cache_stats.files_tracked,
61            cache_stats.total_reads,
62            cache_stats.cache_hits,
63            cache_stats.hit_rate()
64        ));
65
66        out.push(format!(
67            "Input tokens:  {} original → {} sent | {} saved ({:.1}%)",
68            format_tokens(total_original),
69            format_tokens(total_sent),
70            format_tokens(total_saved),
71            pct
72        ));
73
74        let cost_saved = total_saved as f64 / 1_000_000.0 * quote.cost.input_per_m;
75        let cost_without = total_original as f64 / 1_000_000.0 * quote.cost.input_per_m;
76        let cost_with = total_sent as f64 / 1_000_000.0 * quote.cost.input_per_m;
77        out.push(format!(
78            "Cost estimate: ${cost_without:.4} without → ${cost_with:.4} with lean-ctx | ${cost_saved:.4} saved"
79        ));
80    }
81
82    if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
83        let bounces = bt.total_bounces();
84        let wasted = bt.total_wasted_tokens();
85        if bounces > 0 {
86            let adjusted = bt.adjusted_savings(total_saved as usize);
87            out.push(String::new());
88            if crp_mode.is_tdd() {
89                out.push("§bounce".to_string());
90            } else {
91                out.push("Bounce Detection:".to_string());
92            }
93            out.push(format!(
94                "  bounces: {bounces} | wasted: {} tok",
95                format_tokens(wasted as u64)
96            ));
97            out.push(format!(
98                "  adjusted savings: {} tok ({:.1}%)",
99                format_tokens(adjusted.max(0) as u64),
100                if total_original > 0 {
101                    adjusted.max(0) as f64 / total_original as f64 * 100.0
102                } else {
103                    0.0
104                }
105            ));
106        }
107    }
108
109    if !tool_calls.is_empty() {
110        out.push(String::new());
111
112        let sep_w = if crp_mode.is_tdd() { 40 } else { 50 };
113        if crp_mode.is_tdd() {
114            out.push(format!(
115                "{:<12} {:>4} {:>7} {:>7} {:>4}",
116                "tool", "n", "orig", "saved", "%"
117            ));
118        } else {
119            out.push("By Tool:".to_string());
120            out.push(format!(
121                "{:<14} {:>5}  {:>8}  {:>8}  {:>5}",
122                "Tool", "Calls", "Original", "Saved", "Avg%"
123            ));
124        }
125        out.push("─".repeat(sep_w));
126
127        let mut by_tool: HashMap<&str, ToolStats> = HashMap::new();
128        for call in tool_calls {
129            let entry = by_tool.entry(&call.tool).or_default();
130            entry.calls += 1;
131            entry.original += call.original_tokens;
132            entry.saved += call.saved_tokens;
133        }
134
135        let mut sorted: Vec<_> = by_tool
136            .iter()
137            .filter(|(_, ts)| ts.original > 0 || ts.saved > 0)
138            .collect();
139        sorted.sort_by_key(|x| std::cmp::Reverse(x.1.saved));
140
141        for (tool, ts) in &sorted {
142            let avg = if ts.original > 0 {
143                ts.saved as f64 / ts.original as f64 * 100.0
144            } else {
145                0.0
146            };
147            if crp_mode.is_tdd() {
148                out.push(format!(
149                    "{:<12} {:>4} {:>7} {:>7} {:>3.0}%",
150                    tool,
151                    ts.calls,
152                    format_tokens(ts.original as u64),
153                    format_tokens(ts.saved as u64),
154                    avg
155                ));
156            } else {
157                out.push(format!(
158                    "{:<14} {:>5}  {:>8}  {:>8}  {:>4.0}%",
159                    tool,
160                    ts.calls,
161                    format_tokens(ts.original as u64),
162                    format_tokens(ts.saved as u64),
163                    avg
164                ));
165            }
166        }
167
168        let mut by_mode: HashMap<&str, ModeStats> = HashMap::new();
169        for call in tool_calls {
170            if let Some(ref mode) = call.mode {
171                let entry = by_mode.entry(mode).or_default();
172                entry.calls += 1;
173                entry.saved += call.saved_tokens;
174            }
175        }
176
177        if !by_mode.is_empty() {
178            out.push(String::new());
179            if crp_mode.is_tdd() {
180                out.push(format!("{:<12} {:>4} {:>7}", "mode", "n", "saved"));
181            } else {
182                out.push("By Mode:".to_string());
183                out.push(format!("{:<14} {:>5}  {:>8}", "Mode", "Calls", "Saved"));
184            }
185            out.push("─".repeat(if crp_mode.is_tdd() { 28 } else { 30 }));
186
187            let mut sorted_modes: Vec<_> = by_mode.iter().collect();
188            sorted_modes.sort_by_key(|x| std::cmp::Reverse(x.1.saved));
189
190            for (mode, ms) in &sorted_modes {
191                if crp_mode.is_tdd() {
192                    out.push(format!(
193                        "{:<12} {:>4} {:>7}",
194                        mode,
195                        ms.calls,
196                        format_tokens(ms.saved as u64)
197                    ));
198                } else {
199                    out.push(format!(
200                        "{:<14} {:>5}  {:>8}",
201                        mode,
202                        ms.calls,
203                        format_tokens(ms.saved as u64)
204                    ));
205                }
206            }
207        }
208    }
209
210    if !refs.is_empty() {
211        out.push(String::new());
212        if crp_mode.is_tdd() {
213            out.push("§refs:".to_string());
214        } else {
215            out.push("File Refs:".to_string());
216        }
217        let mut ref_list: Vec<_> = refs.iter().collect();
218        ref_list.sort_by_key(|(_, r)| (*r).clone());
219        for (path, r) in &ref_list {
220            let short = crate::core::protocol::shorten_path(path);
221            if let Some(entry) = cache.get(path) {
222                out.push(format!(
223                    "  {r}={short} [{}L {}t r:{}]",
224                    entry.line_count, entry.original_tokens, entry.read_count
225                ));
226            } else {
227                out.push(format!("  {r}={short}"));
228            }
229        }
230    }
231
232    let projected_session =
233        total_saved as f64 / 1_000_000.0 * (quote.cost.input_per_m + quote.cost.output_per_m * 0.3);
234    if projected_session > 0.001 {
235        out.push(String::new());
236        if crp_mode.is_tdd() {
237            out.push(format!(
238                "∴ session savings (incl. thinking): ${projected_session:.3}"
239            ));
240        } else {
241            out.push(format!(
242                "Projected session savings (incl. thinking): ${projected_session:.3}"
243            ));
244        }
245    }
246
247    let cep = compute_cep_compliance(cache, tool_calls);
248    out.push(String::new());
249    if crp_mode.is_tdd() {
250        out.push("§CEP compliance".to_string());
251    } else {
252        out.push("CEP Compliance:".to_string());
253    }
254    out.push(format!(
255        "  Cache utilization: {:.0}%  (hit rate for repeated files)",
256        cep.cache_utilization * 100.0
257    ));
258    out.push(format!(
259        "  Mode diversity:    {:.0}%  (using optimal modes per file)",
260        cep.mode_diversity * 100.0
261    ));
262    out.push(format!(
263        "  Compression rate:  {:.0}%  (overall token reduction)",
264        cep.compression_rate * 100.0
265    ));
266    out.push(format!(
267        "  CEP Score:         {:.0}/100",
268        cep.overall_score * 100.0
269    ));
270
271    let complexity = crate::core::adaptive::classify_from_context(cache);
272    out.push(format!("  Task complexity:   {complexity:?}"));
273
274    out.join("\n")
275}
276
277struct CepCompliance {
278    cache_utilization: f64,
279    mode_diversity: f64,
280    compression_rate: f64,
281    overall_score: f64,
282}
283
284fn compute_cep_compliance(cache: &SessionCache, tool_calls: &[ToolCallRecord]) -> CepCompliance {
285    let stats = cache.get_stats();
286
287    let cache_utilization = stats.hit_rate() / 100.0;
288
289    let modes_used: std::collections::HashSet<&str> = tool_calls
290        .iter()
291        .filter_map(|c| c.mode.as_deref())
292        .collect();
293    let possible_modes = crate::core::budgets::READ_MODE_COUNT;
294    let mode_diversity = (modes_used.len() as f64 / possible_modes).min(1.0);
295
296    let total_original: u64 = tool_calls.iter().map(|c| c.original_tokens as u64).sum();
297    let total_saved: u64 = tool_calls.iter().map(|c| c.saved_tokens as u64).sum();
298    let compression_rate = if total_original > 0 {
299        total_saved as f64 / total_original as f64
300    } else {
301        0.0
302    };
303
304    let overall_score = cache_utilization * 0.3 + mode_diversity * 0.2 + compression_rate * 0.5;
305
306    CepCompliance {
307        cache_utilization,
308        mode_diversity,
309        compression_rate,
310        overall_score,
311    }
312}
313
314fn format_tokens(n: u64) -> String {
315    if n >= 1_000_000 {
316        format!("{:.1}M", n as f64 / 1_000_000.0)
317    } else if n >= 1_000 {
318        format!("{:.1}K", n as f64 / 1_000.0)
319    } else {
320        format!("{n}")
321    }
322}
323
324#[derive(Default)]
325struct ToolStats {
326    calls: u32,
327    original: usize,
328    saved: usize,
329}
330
331#[derive(Default)]
332struct ModeStats {
333    calls: u32,
334    saved: usize,
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_cep_compliance_section_present_tdd() {
343        let cache = SessionCache::new();
344        let calls = vec![ToolCallRecord {
345            tool: "ctx_read".to_string(),
346            original_tokens: 1000,
347            saved_tokens: 300,
348            mode: Some("full".to_string()),
349            duration_ms: 0,
350            timestamp: String::new(),
351        }];
352        let output = handle(&cache, &calls, CrpMode::Tdd);
353        assert!(
354            output.contains("§CEP compliance"),
355            "TDD output must contain CEP compliance section"
356        );
357        assert!(output.contains("Cache utilization:"));
358        assert!(output.contains("Mode diversity:"));
359        assert!(output.contains("Compression rate:"));
360        assert!(output.contains("CEP Score:"));
361        assert!(output.contains("Task complexity:"));
362    }
363
364    #[test]
365    fn test_cep_compliance_section_present_normal() {
366        let cache = SessionCache::new();
367        let calls = vec![];
368        let output = handle(&cache, &calls, CrpMode::Off);
369        assert!(
370            output.contains("CEP Compliance:"),
371            "Normal output must contain CEP Compliance section"
372        );
373        assert!(output.contains("Task complexity:"));
374    }
375
376    #[test]
377    fn test_cep_scores_zero_with_no_calls() {
378        let cache = SessionCache::new();
379        let calls = vec![];
380        let output = handle(&cache, &calls, CrpMode::Tdd);
381        assert!(output.contains("CEP Score:         0/100"));
382        assert!(output.contains("Cache utilization: 0%"));
383    }
384
385    #[test]
386    fn test_format_tokens_units() {
387        assert_eq!(format_tokens(500), "500");
388        assert_eq!(format_tokens(1500), "1.5K");
389        assert_eq!(format_tokens(1_500_000), "1.5M");
390    }
391}