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 ~80 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    /// Value-per-token signal: recorded calls per 1 000 always-on schema tokens.
68    /// The telemetry fallback for #961 when no per-tool outcome eval exists.
69    pub value_per_1k_tokens: f64,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct RuleEntry {
74    pub path: String,
75    pub file_tokens: usize,
76    pub lean_ctx_tokens: usize,
77    pub carries_full: bool,
78    pub clients: Vec<String>,
79    pub action: String,
80}
81
82#[derive(Debug, Clone, Serialize, Default)]
83pub struct KnowledgeHealth {
84    pub total_facts: usize,
85    pub active_facts: usize,
86    pub stale_facts: usize,
87    pub action: String,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct ToolHealthReport {
92    pub tool_profile: String,
93    pub advertised_tools: usize,
94    pub tool_schema_tokens: usize,
95    pub instruction_tokens: usize,
96    pub rules_tokens: usize,
97    /// Tool schemas + MCP instructions + every auto-loaded rules file.
98    pub fixed_total_tokens: usize,
99    pub has_usage_data: bool,
100    pub total_recorded_calls: u64,
101    pub unused_tools: usize,
102    /// Schema tokens spent every session on tools that are never called.
103    pub unused_tool_tokens: usize,
104    /// Low-value tools (unused or low-use) the operator should consider disabling.
105    pub disable_candidates: Vec<String>,
106    /// Schema tokens reclaimable by disabling every [`Self::disable_candidates`].
107    pub reclaimable_tokens: usize,
108    /// Aggregated, copy-pasteable "consider disabling X" recommendation.
109    pub disable_action: String,
110    /// Outcome signal from the latest `eval footprint` artifact (#959), if any:
111    /// whether the tool-schema element as a whole earns its tokens.
112    pub footprint_note: Option<String>,
113    pub tools: Vec<ToolEntry>,
114    pub rules: Vec<RuleEntry>,
115    pub duplicate_clients: Vec<(String, usize)>,
116    pub knowledge: KnowledgeHealth,
117}
118
119fn classify(has_usage: bool, calls: u64, schema_tokens: usize, total_calls: u64) -> ToolStatus {
120    if !has_usage {
121        return ToolStatus::Unknown;
122    }
123    if calls == 0 {
124        return ToolStatus::Unused;
125    }
126    if schema_tokens >= LOW_USE_TOKEN_FLOOR
127        && (calls as f64) < (total_calls as f64) * LOW_USE_CALL_SHARE
128    {
129        return ToolStatus::LowUse;
130    }
131    ToolStatus::Active
132}
133
134/// Recorded calls per 1 000 always-on schema tokens — higher = better value.
135fn value_per_1k(calls: u64, schema_tokens: usize) -> f64 {
136    if schema_tokens == 0 {
137        0.0
138    } else {
139        calls as f64 / schema_tokens as f64 * 1000.0
140    }
141}
142
143fn action_for(status: ToolStatus, calls: u64, schema_tokens: usize) -> String {
144    match status {
145        ToolStatus::Unused => format!(
146            "never called — trim via a leaner tool profile to reclaim {schema_tokens} tok/session"
147        ),
148        ToolStatus::LowUse => {
149            format!("rarely used ({calls}×) yet costs {schema_tokens} tok/session — review")
150        }
151        ToolStatus::Active | ToolStatus::Unknown => String::new(),
152    }
153}
154
155/// Maps alias tool names to their delegate target for usage lookup.
156/// Alias tools (e.g. `shell`) delegate to a canonical tool (e.g. `ctx_shell`)
157/// and stats are recorded under the canonical name. Without this mapping,
158/// `tools health` reports the alias as "never called".
159fn resolve_alias(name: &str) -> Option<&'static str> {
160    match name {
161        "shell" => Some("ctx_shell"),
162        _ => None,
163    }
164}
165
166/// Pure report builder — every input is supplied, so it is fully deterministic
167/// and unit-testable without touching disk or the clock.
168#[must_use]
169pub fn build_report(
170    advertised: &[rmcp::model::Tool],
171    usage: &CostStore,
172    rules: &[RulesFileCost],
173    duplicates: Vec<(String, usize)>,
174    instruction_tokens: usize,
175    tool_profile: String,
176    knowledge: KnowledgeHealth,
177) -> ToolHealthReport {
178    let total_recorded_calls: u64 = usage.tools.values().map(|t| t.total_calls).sum();
179    let has_usage_data = total_recorded_calls > 0;
180
181    let mut tools: Vec<ToolEntry> = advertised
182        .iter()
183        .map(|t| {
184            let name = t.name.as_ref().to_string();
185            let schema_tokens = tool_tokens(t);
186            let (calls, last_used) = usage
187                .tools
188                .get(&name)
189                .or_else(|| resolve_alias(&name).and_then(|a| usage.tools.get(a)))
190                .map_or((0, None), |c| (c.total_calls, c.last_used.clone()));
191            let status = classify(has_usage_data, calls, schema_tokens, total_recorded_calls);
192            let action = action_for(status, calls, schema_tokens);
193            ToolEntry {
194                name,
195                schema_tokens,
196                calls,
197                last_used,
198                status,
199                action,
200                value_per_1k_tokens: value_per_1k(calls, schema_tokens),
201            }
202        })
203        .collect();
204    tools.sort_by(|a, b| a.name.cmp(&b.name));
205
206    let tool_schema_tokens: usize = tools.iter().map(|t| t.schema_tokens).sum();
207    let unused_tools = tools
208        .iter()
209        .filter(|t| t.status == ToolStatus::Unused)
210        .count();
211    let unused_tool_tokens = tools
212        .iter()
213        .filter(|t| t.status == ToolStatus::Unused)
214        .map(|t| t.schema_tokens)
215        .sum();
216
217    // Low-value tools the operator should consider disabling: never-called or
218    // heavy-but-rarely-called. `tools` is already name-sorted → deterministic.
219    let low_value = |t: &&ToolEntry| matches!(t.status, ToolStatus::Unused | ToolStatus::LowUse);
220    let disable_candidates: Vec<String> = tools
221        .iter()
222        .filter(low_value)
223        .map(|t| t.name.clone())
224        .collect();
225    let reclaimable_tokens: usize = tools
226        .iter()
227        .filter(low_value)
228        .map(|t| t.schema_tokens)
229        .sum();
230    let disable_action = if disable_candidates.is_empty() {
231        String::new()
232    } else {
233        format!(
234            "consider disabling {} low-value tool(s) to reclaim {reclaimable_tokens} tok/session: {} — apply via `disabled_tools` in config or a leaner `tool_profile`",
235            disable_candidates.len(),
236            disable_candidates.join(", ")
237        )
238    };
239
240    let dup_clients: std::collections::HashSet<&str> =
241        duplicates.iter().map(|(c, _)| c.as_str()).collect();
242    let rules_out: Vec<RuleEntry> = rules
243        .iter()
244        .map(|r| {
245            let is_dup = r.carries_full && r.clients.iter().any(|c| dup_clients.contains(c));
246            let action = if is_dup {
247                "duplicate full lean-ctx source — run `lean-ctx rules dedup --apply`".to_string()
248            } else {
249                String::new()
250            };
251            RuleEntry {
252                path: r.path.clone(),
253                file_tokens: r.file_tokens,
254                lean_ctx_tokens: r.lean_ctx_tokens,
255                carries_full: r.carries_full,
256                clients: r.clients.iter().map(|c| (*c).to_string()).collect(),
257                action,
258            }
259        })
260        .collect();
261
262    let rules_tokens: usize = rules_out.iter().map(|r| r.file_tokens).sum();
263    let fixed_total_tokens = tool_schema_tokens + instruction_tokens + rules_tokens;
264
265    ToolHealthReport {
266        tool_profile,
267        advertised_tools: tools.len(),
268        tool_schema_tokens,
269        instruction_tokens,
270        rules_tokens,
271        fixed_total_tokens,
272        has_usage_data,
273        total_recorded_calls,
274        unused_tools,
275        unused_tool_tokens,
276        disable_candidates,
277        reclaimable_tokens,
278        disable_action,
279        footprint_note: None,
280        tools,
281        rules: rules_out,
282        duplicate_clients: duplicates,
283        knowledge,
284    }
285}
286
287/// Reads the latest `eval footprint` artifact (#959) and summarises whether the
288/// tool-schema element earns its tokens — the per-outcome signal that complements
289/// the telemetry-based [`value_per_1k`]. Returns `None` when no run is on disk.
290fn latest_footprint_note() -> Option<String> {
291    use crate::core::eval_ab::footprint::{FootprintReport, InjectedElement};
292
293    let dir = crate::core::data_dir::lean_ctx_data_dir()
294        .ok()?
295        .join("eval");
296    let mut artifacts: Vec<(std::time::SystemTime, std::path::PathBuf)> = std::fs::read_dir(&dir)
297        .ok()?
298        .flatten()
299        .filter_map(|e| {
300            let path = e.path();
301            let name = path.file_name()?.to_str()?.to_string();
302            let is_json = path
303                .extension()
304                .and_then(|x| x.to_str())
305                .is_some_and(|x| x.eq_ignore_ascii_case("json"));
306            if name.starts_with("footprint-report-v1_") && is_json {
307                Some((e.metadata().ok()?.modified().ok()?, path))
308            } else {
309                None
310            }
311        })
312        .collect();
313    artifacts.sort_by_key(|a| a.0);
314    let (_, path) = artifacts.last()?;
315
316    let raw = std::fs::read_to_string(path).ok()?;
317    let report: FootprintReport = serde_json::from_str(&raw).ok()?;
318    let schemas = report
319        .elements
320        .iter()
321        .find(|e| e.element == InjectedElement::ToolSchemas)?;
322    let verdict = if schemas.prune_recommended {
323        "PRUNE-recommended"
324    } else {
325        "earns its cost"
326    };
327    Some(format!(
328        "footprint eval ({}): tool schemas {verdict} (Δpass {:+.0}%, cost {} tok)",
329        report.suite,
330        schemas.pass_rate_delta * 100.0,
331        schemas.token_cost
332    ))
333}
334
335fn resolve_tool_profile() -> String {
336    let cfg = crate::core::config::Config::load();
337    if crate::server::tool_visibility::explicit_profile(&cfg) {
338        cfg.tool_profile_effective().as_str().to_string()
339    } else {
340        "lean (default)".to_string()
341    }
342}
343
344fn knowledge_health(project: &Path) -> KnowledgeHealth {
345    let Some(knowledge) =
346        crate::core::knowledge::ProjectKnowledge::load(&project.to_string_lossy())
347    else {
348        return KnowledgeHealth::default();
349    };
350    let total = knowledge.facts.len();
351    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
352    let now = chrono::Utc::now();
353    let stale = current
354        .iter()
355        .filter(|f| (now - f.created_at).num_days() > STALE_FACT_DAYS && f.retrieval_count == 0)
356        .count();
357    let action = if stale > 0 {
358        format!(
359            "{stale} stale fact(s) (>{STALE_FACT_DAYS}d, never retrieved) — review with `lean-ctx knowledge`"
360        )
361    } else {
362        String::new()
363    };
364    KnowledgeHealth {
365        total_facts: total,
366        active_facts: current.len(),
367        stale_facts: stale,
368        action,
369    }
370}
371
372/// Gathers real on-disk telemetry for `home`/`project` and builds the report.
373#[must_use]
374pub fn compute(home: &Path, project: &Path) -> ToolHealthReport {
375    let advertised = crate::server::tool_visibility::advertised_tool_defs_default();
376    let usage = CostStore::load();
377    let rules = collect_rules_files(home, project);
378    let duplicates = duplicate_clients(&rules);
379    let instructions = crate::instructions::build_instructions(crate::tools::CrpMode::effective());
380    let instruction_tokens = crate::core::tokens::count_tokens(&instructions);
381    let knowledge = knowledge_health(project);
382    let mut report = build_report(
383        &advertised,
384        &usage,
385        &rules,
386        duplicates,
387        instruction_tokens,
388        resolve_tool_profile(),
389        knowledge,
390    );
391    report.footprint_note = latest_footprint_note();
392    report
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::core::a2a::cost_attribution::ToolCost;
399
400    fn tool(name: &'static str) -> rmcp::model::Tool {
401        crate::tool_defs::tool_def(
402            name,
403            "a representative description used to give the schema some token weight",
404            serde_json::json!({
405                "type": "object",
406                "properties": { "path": { "type": "string", "description": "file path" } }
407            }),
408        )
409    }
410
411    fn usage_with(calls: &[(&str, u64)]) -> CostStore {
412        let mut store = CostStore::default();
413        for (name, n) in calls {
414            store.tools.insert(
415                (*name).to_string(),
416                ToolCost {
417                    tool_name: (*name).to_string(),
418                    total_calls: *n,
419                    last_used: Some("2026-06-01T00:00:00+00:00".to_string()),
420                    ..Default::default()
421                },
422            );
423        }
424        store
425    }
426
427    #[test]
428    fn classify_unknown_without_usage_history() {
429        assert_eq!(classify(false, 0, 500, 0), ToolStatus::Unknown);
430        assert_eq!(classify(false, 9, 500, 0), ToolStatus::Unknown);
431    }
432
433    #[test]
434    fn classify_unused_when_history_exists_but_tool_never_called() {
435        assert_eq!(classify(true, 0, 500, 1000), ToolStatus::Unused);
436    }
437
438    #[test]
439    fn classify_low_use_for_expensive_rarely_called_tool() {
440        // 1 call out of 10_000, heavy schema → low-use.
441        assert_eq!(classify(true, 1, 400, 10_000), ToolStatus::LowUse);
442        // Same rarity but a cheap schema → still active (not worth flagging).
443        assert_eq!(classify(true, 1, 50, 10_000), ToolStatus::Active);
444    }
445
446    #[test]
447    fn classify_active_for_well_used_tool() {
448        assert_eq!(classify(true, 500, 400, 1000), ToolStatus::Active);
449    }
450
451    #[test]
452    fn build_report_flags_unused_and_sorts_tools() {
453        let advertised = vec![tool("ctx_search"), tool("ctx_read"), tool("ctx_shell")];
454        // History exists (ctx_read used), ctx_search/ctx_shell never called.
455        let usage = usage_with(&[("ctx_read", 40)]);
456        let report = build_report(
457            &advertised,
458            &usage,
459            &[],
460            Vec::new(),
461            100,
462            "lean (default)".to_string(),
463            KnowledgeHealth::default(),
464        );
465
466        assert!(report.has_usage_data);
467        assert_eq!(report.total_recorded_calls, 40);
468        // Sorted alphabetically.
469        let names: Vec<&str> = report.tools.iter().map(|t| t.name.as_str()).collect();
470        assert_eq!(names, vec!["ctx_read", "ctx_search", "ctx_shell"]);
471        // Two unused tools, each contributing its schema tokens.
472        assert_eq!(report.unused_tools, 2);
473        assert!(report.unused_tool_tokens > 0);
474        let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
475        assert_eq!(read.status, ToolStatus::Active);
476        assert!(read.last_used.is_some());
477        // Fixed total = tool schemas + instructions(100) + rules(0).
478        assert_eq!(
479            report.fixed_total_tokens,
480            report.tool_schema_tokens + 100 + report.rules_tokens
481        );
482    }
483
484    #[test]
485    fn value_per_1k_rewards_cheap_well_used_tools() {
486        assert!(value_per_1k(100, 50) > value_per_1k(100, 500));
487        assert_eq!(value_per_1k(0, 100), 0.0);
488        assert_eq!(value_per_1k(10, 0), 0.0, "no schema cost → no division");
489    }
490
491    #[test]
492    fn build_report_recommends_disabling_low_value_tools() {
493        let advertised = vec![tool("ctx_read"), tool("ctx_search"), tool("ctx_shell")];
494        // History exists; ctx_search + ctx_shell never called → disable candidates.
495        let usage = usage_with(&[("ctx_read", 40)]);
496        let report = build_report(
497            &advertised,
498            &usage,
499            &[],
500            Vec::new(),
501            0,
502            "lean (default)".to_string(),
503            KnowledgeHealth::default(),
504        );
505        assert!(
506            report
507                .disable_candidates
508                .contains(&"ctx_search".to_string())
509        );
510        assert!(report.disable_candidates.contains(&"ctx_shell".to_string()));
511        assert!(
512            !report.disable_candidates.contains(&"ctx_read".to_string()),
513            "an active tool is never a disable candidate"
514        );
515        assert!(report.reclaimable_tokens > 0);
516        assert!(report.disable_action.contains("consider disabling"));
517        assert!(
518            report.footprint_note.is_none(),
519            "the pure builder never reads disk artifacts"
520        );
521        let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
522        assert!(read.value_per_1k_tokens > 0.0);
523    }
524
525    #[test]
526    fn build_report_unknown_status_without_history() {
527        let advertised = vec![tool("ctx_read")];
528        let report = build_report(
529            &advertised,
530            &CostStore::default(),
531            &[],
532            Vec::new(),
533            0,
534            "lean (default)".to_string(),
535            KnowledgeHealth::default(),
536        );
537        assert!(!report.has_usage_data);
538        assert_eq!(report.unused_tools, 0, "never flag rot without history");
539        assert_eq!(report.tools[0].status, ToolStatus::Unknown);
540    }
541
542    #[test]
543    fn build_report_marks_duplicate_rules() {
544        let rules = vec![
545            RulesFileCost {
546                path: "a/.cursor/rules/lean-ctx.mdc".into(),
547                file_tokens: 200,
548                lean_ctx_tokens: 200,
549                carries_full: true,
550                clients: vec!["cursor"],
551            },
552            RulesFileCost {
553                path: "a/.cursorrules".into(),
554                file_tokens: 150,
555                lean_ctx_tokens: 150,
556                carries_full: true,
557                clients: vec!["cursor"],
558            },
559        ];
560        let dups = duplicate_clients(&rules);
561        let report = build_report(
562            &[],
563            &CostStore::default(),
564            &rules,
565            dups,
566            0,
567            "lean (default)".to_string(),
568            KnowledgeHealth::default(),
569        );
570        assert_eq!(report.rules.len(), 2);
571        assert!(
572            report.rules.iter().all(|r| r.action.contains("dedup")),
573            "both cursor full sources flagged as duplicates"
574        );
575        assert_eq!(report.rules_tokens, 350);
576    }
577
578    #[test]
579    fn compute_smoke_runs_and_counts_advertised_tools() {
580        // `advertised_tool_defs_default()` reads process-global env (tool
581        // profile, unified/full mode) and config, so it is not pure. Isolate the
582        // data dir and serialize on the shared test-env lock — otherwise a
583        // concurrent env-mutating test (e.g. the minimal-arm overhead test that
584        // sets LEAN_CTX_TOOL_PROFILE=minimal) can flip the profile between the two
585        // calls below, making the counts disagree. Latent race; surfaced once a
586        // slower sibling test shifted parallel scheduling (#945).
587        let _iso = crate::core::data_dir::isolated_data_dir();
588        let tmp = tempfile::tempdir().unwrap();
589        let report = compute(tmp.path(), tmp.path());
590        let expected = crate::server::tool_visibility::advertised_tool_defs_default().len();
591        assert_eq!(report.advertised_tools, expected);
592        assert!(report.tool_schema_tokens > 0);
593        assert!(report.fixed_total_tokens >= report.tool_schema_tokens);
594    }
595
596    /// GH #838: `shell` alias inherits usage from `ctx_shell`.
597    #[test]
598    fn shell_alias_inherits_ctx_shell_usage_838() {
599        let shell_tool = tool("shell");
600        let mut usage = CostStore::default();
601        let tc = ToolCost {
602            total_calls: 42,
603            last_used: Some("2026-07-15".to_string()),
604            ..ToolCost::default()
605        };
606        usage.tools.insert("ctx_shell".to_string(), tc);
607        let report = build_report(
608            &[shell_tool],
609            &usage,
610            &[],
611            Vec::new(),
612            0,
613            "lean".to_string(),
614            KnowledgeHealth::default(),
615        );
616        assert_eq!(report.tools[0].name, "shell");
617        assert_eq!(
618            report.tools[0].calls, 42,
619            "shell must inherit ctx_shell calls"
620        );
621        assert_ne!(
622            report.tools[0].status,
623            ToolStatus::Unused,
624            "shell must not be flagged unused"
625        );
626    }
627}