Skip to main content

lean_ctx/core/
tool_health.rs

1//! `lean-ctx tools health` — does every advertised MCP tool and injected rule
2//! earn its always-on token cost? (#848)
3//!
4//! lean-ctx ships ~77 MCP tools plus injected rules files; the thesis is "every
5//! token earns its place". This report cross-references the *fixed cost* of each
6//! advertised tool schema and rules file with *recorded usage* (the
7//! [`CostStore`] post-dispatch ledger) and flags "rot":
8//!
9//! * tools that cost schema tokens every session but are never called,
10//! * rules files that bill the same guidance to a client more than once,
11//! * stale knowledge facts (old and never retrieved).
12//!
13//! Deterministic and local-only: it reads existing on-disk telemetry, sorts
14//! everything stably, and adds **no** new hot-path cost (`last_used` rides the
15//! existing cost-attribution write). It never auto-applies anything — every
16//! finding is a suggestion the operator acts on explicitly.
17
18use std::path::Path;
19
20use serde::Serialize;
21
22use crate::core::a2a::cost_attribution::CostStore;
23use crate::core::context_overhead::tool_tokens;
24use crate::core::rules_overhead::{RulesFileCost, collect_rules_files, duplicate_clients};
25
26/// A tool whose schema costs >= this many tokens *and* is used for <1% of all
27/// calls is flagged `LowUse` — expensive surface that barely pays its way.
28const LOW_USE_TOKEN_FLOOR: usize = 150;
29/// Share of total recorded calls below which a heavy tool counts as `LowUse`.
30const LOW_USE_CALL_SHARE: f64 = 0.01;
31/// A fact older than this (days) that was never retrieved is a prune candidate.
32const STALE_FACT_DAYS: i64 = 30;
33
34#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum ToolStatus {
37    /// Called regularly — earns its schema cost.
38    Active,
39    /// Used, but rarely, while carrying a heavy schema.
40    LowUse,
41    /// Never called in the recorded history — pure rot.
42    Unused,
43    /// No usage telemetry yet — cannot judge.
44    Unknown,
45}
46
47impl ToolStatus {
48    #[must_use]
49    pub fn label(self) -> &'static str {
50        match self {
51            ToolStatus::Active => "active",
52            ToolStatus::LowUse => "low-use",
53            ToolStatus::Unused => "unused",
54            ToolStatus::Unknown => "unknown",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct ToolEntry {
61    pub name: String,
62    pub schema_tokens: usize,
63    pub calls: u64,
64    pub last_used: Option<String>,
65    pub status: ToolStatus,
66    pub action: String,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct RuleEntry {
71    pub path: String,
72    pub file_tokens: usize,
73    pub lean_ctx_tokens: usize,
74    pub carries_full: bool,
75    pub clients: Vec<String>,
76    pub action: String,
77}
78
79#[derive(Debug, Clone, Serialize, Default)]
80pub struct KnowledgeHealth {
81    pub total_facts: usize,
82    pub active_facts: usize,
83    pub stale_facts: usize,
84    pub action: String,
85}
86
87#[derive(Debug, Clone, Serialize)]
88pub struct ToolHealthReport {
89    pub tool_profile: String,
90    pub advertised_tools: usize,
91    pub tool_schema_tokens: usize,
92    pub instruction_tokens: usize,
93    pub rules_tokens: usize,
94    /// Tool schemas + MCP instructions + every auto-loaded rules file.
95    pub fixed_total_tokens: usize,
96    pub has_usage_data: bool,
97    pub total_recorded_calls: u64,
98    pub unused_tools: usize,
99    /// Schema tokens spent every session on tools that are never called.
100    pub unused_tool_tokens: usize,
101    pub tools: Vec<ToolEntry>,
102    pub rules: Vec<RuleEntry>,
103    pub duplicate_clients: Vec<(String, usize)>,
104    pub knowledge: KnowledgeHealth,
105}
106
107fn classify(has_usage: bool, calls: u64, schema_tokens: usize, total_calls: u64) -> ToolStatus {
108    if !has_usage {
109        return ToolStatus::Unknown;
110    }
111    if calls == 0 {
112        return ToolStatus::Unused;
113    }
114    if schema_tokens >= LOW_USE_TOKEN_FLOOR
115        && (calls as f64) < (total_calls as f64) * LOW_USE_CALL_SHARE
116    {
117        return ToolStatus::LowUse;
118    }
119    ToolStatus::Active
120}
121
122fn action_for(status: ToolStatus, calls: u64, schema_tokens: usize) -> String {
123    match status {
124        ToolStatus::Unused => format!(
125            "never called — trim via a leaner tool profile to reclaim {schema_tokens} tok/session"
126        ),
127        ToolStatus::LowUse => {
128            format!("rarely used ({calls}×) yet costs {schema_tokens} tok/session — review")
129        }
130        ToolStatus::Active | ToolStatus::Unknown => String::new(),
131    }
132}
133
134/// Pure report builder — every input is supplied, so it is fully deterministic
135/// and unit-testable without touching disk or the clock.
136#[must_use]
137pub fn build_report(
138    advertised: &[rmcp::model::Tool],
139    usage: &CostStore,
140    rules: &[RulesFileCost],
141    duplicates: Vec<(String, usize)>,
142    instruction_tokens: usize,
143    tool_profile: String,
144    knowledge: KnowledgeHealth,
145) -> ToolHealthReport {
146    let total_recorded_calls: u64 = usage.tools.values().map(|t| t.total_calls).sum();
147    let has_usage_data = total_recorded_calls > 0;
148
149    let mut tools: Vec<ToolEntry> = advertised
150        .iter()
151        .map(|t| {
152            let name = t.name.as_ref().to_string();
153            let schema_tokens = tool_tokens(t);
154            let (calls, last_used) = usage
155                .tools
156                .get(&name)
157                .map_or((0, None), |c| (c.total_calls, c.last_used.clone()));
158            let status = classify(has_usage_data, calls, schema_tokens, total_recorded_calls);
159            let action = action_for(status, calls, schema_tokens);
160            ToolEntry {
161                name,
162                schema_tokens,
163                calls,
164                last_used,
165                status,
166                action,
167            }
168        })
169        .collect();
170    tools.sort_by(|a, b| a.name.cmp(&b.name));
171
172    let tool_schema_tokens: usize = tools.iter().map(|t| t.schema_tokens).sum();
173    let unused_tools = tools
174        .iter()
175        .filter(|t| t.status == ToolStatus::Unused)
176        .count();
177    let unused_tool_tokens = tools
178        .iter()
179        .filter(|t| t.status == ToolStatus::Unused)
180        .map(|t| t.schema_tokens)
181        .sum();
182
183    let dup_clients: std::collections::HashSet<&str> =
184        duplicates.iter().map(|(c, _)| c.as_str()).collect();
185    let rules_out: Vec<RuleEntry> = rules
186        .iter()
187        .map(|r| {
188            let is_dup = r.carries_full && r.clients.iter().any(|c| dup_clients.contains(c));
189            let action = if is_dup {
190                "duplicate full lean-ctx source — run `lean-ctx rules dedup --apply`".to_string()
191            } else {
192                String::new()
193            };
194            RuleEntry {
195                path: r.path.clone(),
196                file_tokens: r.file_tokens,
197                lean_ctx_tokens: r.lean_ctx_tokens,
198                carries_full: r.carries_full,
199                clients: r.clients.iter().map(|c| (*c).to_string()).collect(),
200                action,
201            }
202        })
203        .collect();
204
205    let rules_tokens: usize = rules_out.iter().map(|r| r.file_tokens).sum();
206    let fixed_total_tokens = tool_schema_tokens + instruction_tokens + rules_tokens;
207
208    ToolHealthReport {
209        tool_profile,
210        advertised_tools: tools.len(),
211        tool_schema_tokens,
212        instruction_tokens,
213        rules_tokens,
214        fixed_total_tokens,
215        has_usage_data,
216        total_recorded_calls,
217        unused_tools,
218        unused_tool_tokens,
219        tools,
220        rules: rules_out,
221        duplicate_clients: duplicates,
222        knowledge,
223    }
224}
225
226fn resolve_tool_profile() -> String {
227    let cfg = crate::core::config::Config::load();
228    if crate::server::tool_visibility::explicit_profile(&cfg) {
229        cfg.tool_profile_effective().as_str().to_string()
230    } else {
231        "lean (default)".to_string()
232    }
233}
234
235fn knowledge_health(project: &Path) -> KnowledgeHealth {
236    let Some(knowledge) =
237        crate::core::knowledge::ProjectKnowledge::load(&project.to_string_lossy())
238    else {
239        return KnowledgeHealth::default();
240    };
241    let total = knowledge.facts.len();
242    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
243    let now = chrono::Utc::now();
244    let stale = current
245        .iter()
246        .filter(|f| (now - f.created_at).num_days() > STALE_FACT_DAYS && f.retrieval_count == 0)
247        .count();
248    let action = if stale > 0 {
249        format!(
250            "{stale} stale fact(s) (>{STALE_FACT_DAYS}d, never retrieved) — review with `lean-ctx knowledge`"
251        )
252    } else {
253        String::new()
254    };
255    KnowledgeHealth {
256        total_facts: total,
257        active_facts: current.len(),
258        stale_facts: stale,
259        action,
260    }
261}
262
263/// Gathers real on-disk telemetry for `home`/`project` and builds the report.
264#[must_use]
265pub fn compute(home: &Path, project: &Path) -> ToolHealthReport {
266    let advertised = crate::server::tool_visibility::advertised_tool_defs_default();
267    let usage = CostStore::load();
268    let rules = collect_rules_files(home, project);
269    let duplicates = duplicate_clients(&rules);
270    let instructions = crate::instructions::build_instructions(crate::tools::CrpMode::effective());
271    let instruction_tokens = crate::core::tokens::count_tokens(&instructions);
272    let knowledge = knowledge_health(project);
273    build_report(
274        &advertised,
275        &usage,
276        &rules,
277        duplicates,
278        instruction_tokens,
279        resolve_tool_profile(),
280        knowledge,
281    )
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::core::a2a::cost_attribution::ToolCost;
288
289    fn tool(name: &'static str) -> rmcp::model::Tool {
290        crate::tool_defs::tool_def(
291            name,
292            "a representative description used to give the schema some token weight",
293            serde_json::json!({
294                "type": "object",
295                "properties": { "path": { "type": "string", "description": "file path" } }
296            }),
297        )
298    }
299
300    fn usage_with(calls: &[(&str, u64)]) -> CostStore {
301        let mut store = CostStore::default();
302        for (name, n) in calls {
303            store.tools.insert(
304                (*name).to_string(),
305                ToolCost {
306                    tool_name: (*name).to_string(),
307                    total_calls: *n,
308                    last_used: Some("2026-06-01T00:00:00+00:00".to_string()),
309                    ..Default::default()
310                },
311            );
312        }
313        store
314    }
315
316    #[test]
317    fn classify_unknown_without_usage_history() {
318        assert_eq!(classify(false, 0, 500, 0), ToolStatus::Unknown);
319        assert_eq!(classify(false, 9, 500, 0), ToolStatus::Unknown);
320    }
321
322    #[test]
323    fn classify_unused_when_history_exists_but_tool_never_called() {
324        assert_eq!(classify(true, 0, 500, 1000), ToolStatus::Unused);
325    }
326
327    #[test]
328    fn classify_low_use_for_expensive_rarely_called_tool() {
329        // 1 call out of 10_000, heavy schema → low-use.
330        assert_eq!(classify(true, 1, 400, 10_000), ToolStatus::LowUse);
331        // Same rarity but a cheap schema → still active (not worth flagging).
332        assert_eq!(classify(true, 1, 50, 10_000), ToolStatus::Active);
333    }
334
335    #[test]
336    fn classify_active_for_well_used_tool() {
337        assert_eq!(classify(true, 500, 400, 1000), ToolStatus::Active);
338    }
339
340    #[test]
341    fn build_report_flags_unused_and_sorts_tools() {
342        let advertised = vec![tool("ctx_search"), tool("ctx_read"), tool("ctx_shell")];
343        // History exists (ctx_read used), ctx_search/ctx_shell never called.
344        let usage = usage_with(&[("ctx_read", 40)]);
345        let report = build_report(
346            &advertised,
347            &usage,
348            &[],
349            Vec::new(),
350            100,
351            "lean (default)".to_string(),
352            KnowledgeHealth::default(),
353        );
354
355        assert!(report.has_usage_data);
356        assert_eq!(report.total_recorded_calls, 40);
357        // Sorted alphabetically.
358        let names: Vec<&str> = report.tools.iter().map(|t| t.name.as_str()).collect();
359        assert_eq!(names, vec!["ctx_read", "ctx_search", "ctx_shell"]);
360        // Two unused tools, each contributing its schema tokens.
361        assert_eq!(report.unused_tools, 2);
362        assert!(report.unused_tool_tokens > 0);
363        let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
364        assert_eq!(read.status, ToolStatus::Active);
365        assert!(read.last_used.is_some());
366        // Fixed total = tool schemas + instructions(100) + rules(0).
367        assert_eq!(
368            report.fixed_total_tokens,
369            report.tool_schema_tokens + 100 + report.rules_tokens
370        );
371    }
372
373    #[test]
374    fn build_report_unknown_status_without_history() {
375        let advertised = vec![tool("ctx_read")];
376        let report = build_report(
377            &advertised,
378            &CostStore::default(),
379            &[],
380            Vec::new(),
381            0,
382            "lean (default)".to_string(),
383            KnowledgeHealth::default(),
384        );
385        assert!(!report.has_usage_data);
386        assert_eq!(report.unused_tools, 0, "never flag rot without history");
387        assert_eq!(report.tools[0].status, ToolStatus::Unknown);
388    }
389
390    #[test]
391    fn build_report_marks_duplicate_rules() {
392        let rules = vec![
393            RulesFileCost {
394                path: "a/.cursor/rules/lean-ctx.mdc".into(),
395                file_tokens: 200,
396                lean_ctx_tokens: 200,
397                carries_full: true,
398                clients: vec!["cursor"],
399            },
400            RulesFileCost {
401                path: "a/.cursorrules".into(),
402                file_tokens: 150,
403                lean_ctx_tokens: 150,
404                carries_full: true,
405                clients: vec!["cursor"],
406            },
407        ];
408        let dups = duplicate_clients(&rules);
409        let report = build_report(
410            &[],
411            &CostStore::default(),
412            &rules,
413            dups,
414            0,
415            "lean (default)".to_string(),
416            KnowledgeHealth::default(),
417        );
418        assert_eq!(report.rules.len(), 2);
419        assert!(
420            report.rules.iter().all(|r| r.action.contains("dedup")),
421            "both cursor full sources flagged as duplicates"
422        );
423        assert_eq!(report.rules_tokens, 350);
424    }
425
426    #[test]
427    fn compute_smoke_runs_and_counts_advertised_tools() {
428        let tmp = tempfile::tempdir().unwrap();
429        let report = compute(tmp.path(), tmp.path());
430        let expected = crate::server::tool_visibility::advertised_tool_defs_default().len();
431        assert_eq!(report.advertised_tools, expected);
432        assert!(report.tool_schema_tokens > 0);
433        assert!(report.fixed_total_tokens >= report.tool_schema_tokens);
434    }
435}