Skip to main content

lean_ctx/core/
context_overhead.rs

1//! Honest accounting of the fixed per-turn context lean-ctx injects (GitHub #361).
2//!
3//! Three components ride every request and — on a provider WITHOUT prompt caching
4//! — are re-billed on every turn:
5//!  - the exposed MCP **tool schemas** (description + input schema of each tool),
6//!  - the MCP **server instructions** block, and
7//!  - the **rules block** lean-ctx writes into the host's instruction file
8//!    (`CLAUDE.md` / `AGENTS.md`).
9//!
10//! `lean-ctx gain` measures *compression on lean-ctx-touched reads* — its
11//! denominator is lean-ctx traffic, not the provider bill. On a phase-isolated /
12//! non-caching workload (separate process per phase, no provider cache) the
13//! cached-re-read lever has no surface, so the headline can read net-positive
14//! while the bill moved net-negative. Surfacing this overhead — and stating the
15//! denominator — keeps the meter honest.
16//!
17//! Net bill impact ≈ `gross_saved_tokens − total_tokens() × turns`.
18
19use std::sync::OnceLock;
20
21use crate::core::tokens::count_tokens;
22
23/// A measured breakdown, in tokens, of the per-turn context lean-ctx adds.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct ContextOverhead {
26    /// Number of MCP tools exposed (the schema-bearing surface).
27    pub tool_count: usize,
28    /// Tokens for all exposed tool descriptions + input schemas.
29    pub tool_schema_tokens: usize,
30    /// Tokens for the MCP server instructions block (capped at the instruction budget).
31    pub instruction_tokens: usize,
32    /// Tokens for the rules block injected into the host instruction file.
33    pub rules_block_tokens: usize,
34}
35
36impl ContextOverhead {
37    /// Total per-turn overhead in tokens.
38    #[must_use]
39    pub fn total_tokens(&self) -> usize {
40        self.tool_schema_tokens + self.instruction_tokens + self.rules_block_tokens
41    }
42
43    /// Process-cached overhead. The tool surface and rules block are static and
44    /// the instruction block varies only with slow-moving session state, so a
45    /// once-per-process measurement is the right tradeoff for callers that render
46    /// repeatedly (the `gain` dashboard re-renders every second in `--live`) —
47    /// it avoids per-tick disk I/O and re-tokenization.
48    #[must_use]
49    pub fn cached() -> Self {
50        static CACHE: OnceLock<ContextOverhead> = OnceLock::new();
51        *CACHE.get_or_init(Self::measure)
52    }
53
54    /// Measure the overhead for the currently-configured MCP surface. Reads the
55    /// effective tool profile (minimal vs full) and CRP mode from config, so the
56    /// number reflects what this install actually advertises.
57    #[must_use]
58    pub fn measure() -> Self {
59        let cfg = crate::core::config::Config::load();
60        let tools = if cfg.minimal_overhead_effective() {
61            crate::tool_defs::lazy_tool_defs()
62        } else {
63            crate::tool_defs::granular_tool_defs()
64        };
65        let tool_count = tools.len();
66        let tool_schema_tokens = tools.iter().map(tool_tokens).sum();
67
68        let instructions =
69            crate::instructions::build_instructions(crate::tools::CrpMode::effective());
70        let instruction_tokens = count_tokens(&instructions);
71
72        let rules_block_tokens = count_tokens(crate::rules_inject::canonical_rules_block());
73
74        Self {
75            tool_count,
76            tool_schema_tokens,
77            instruction_tokens,
78            rules_block_tokens,
79        }
80    }
81}
82
83/// Description + input-schema tokens for one tool definition — exactly the two
84/// fields a client re-sends in every request's tool list.
85fn tool_tokens(t: &rmcp::model::Tool) -> usize {
86    let desc = t
87        .description
88        .as_ref()
89        .map_or(0, |d| count_tokens(d.as_ref()));
90    let schema = count_tokens(&serde_json::to_string(&t.input_schema).unwrap_or_default());
91    desc + schema
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn measure_reports_nonzero_components() {
100        let o = ContextOverhead::measure();
101        assert!(o.tool_count > 0, "must expose at least one tool");
102        assert!(o.tool_schema_tokens > 0, "tool schemas carry tokens");
103        assert!(o.instruction_tokens > 0, "instructions carry tokens");
104        assert!(o.rules_block_tokens > 0, "rules block carries tokens");
105        assert_eq!(
106            o.total_tokens(),
107            o.tool_schema_tokens + o.instruction_tokens + o.rules_block_tokens
108        );
109    }
110
111    #[test]
112    fn total_is_sum_of_parts() {
113        let o = ContextOverhead {
114            tool_count: 10,
115            tool_schema_tokens: 100,
116            instruction_tokens: 200,
117            rules_block_tokens: 50,
118        };
119        assert_eq!(o.total_tokens(), 350);
120    }
121}