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. Uses the
55    /// same advertisement policy as the live `tools/list` handler (candidate
56    /// set, profile gates, invoker, description compression), so the number
57    /// reflects what this install actually advertises (#572).
58    #[must_use]
59    pub fn measure() -> Self {
60        let tools = crate::server::tool_visibility::advertised_tool_defs_default();
61        let tool_count = tools.len();
62        let tool_schema_tokens = tools.iter().map(tool_tokens).sum();
63
64        let instructions =
65            crate::instructions::build_instructions(crate::tools::CrpMode::effective());
66        let instruction_tokens = count_tokens(&instructions);
67
68        // The rules block only rides every turn when lean-ctx actually injects it
69        // into the host instruction file. With `rules_injection = off` no file is
70        // written (the `rules_inject` injectors early-return), so it adds zero
71        // per-turn overhead — counting it would overstate the faithful-arm tax and
72        // make the net-of-injection figure pessimistic (#361).
73        let rules_block_tokens = if crate::core::config::Config::load().rules_injection_effective()
74            == crate::core::config::RulesInjection::Off
75        {
76            0
77        } else {
78            count_tokens(crate::rules_inject::canonical_rules_block())
79        };
80
81        Self {
82            tool_count,
83            tool_schema_tokens,
84            instruction_tokens,
85            rules_block_tokens,
86        }
87    }
88}
89
90/// Description + input-schema tokens for one tool definition — exactly the two
91/// fields a client re-sends in every request's tool list.
92pub fn tool_tokens(t: &rmcp::model::Tool) -> usize {
93    let desc = t
94        .description
95        .as_ref()
96        .map_or(0, |d| count_tokens(d.as_ref()));
97    let schema = count_tokens(&serde_json::to_string(&t.input_schema).unwrap_or_default());
98    desc + schema
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn measure_reports_nonzero_components() {
107        // Isolated (default) config: shared rules injection, no pinned profile —
108        // every component carries tokens. `isolated_data_dir` also holds the env
109        // lock, so a concurrent test toggling the knobs can't perturb this.
110        let _iso = crate::core::data_dir::isolated_data_dir();
111        let o = ContextOverhead::measure();
112        assert!(o.tool_count > 0, "must expose at least one tool");
113        assert!(o.tool_schema_tokens > 0, "tool schemas carry tokens");
114        assert!(o.instruction_tokens > 0, "instructions carry tokens");
115        assert!(o.rules_block_tokens > 0, "rules block carries tokens");
116        assert_eq!(
117            o.total_tokens(),
118            o.tool_schema_tokens + o.instruction_tokens + o.rules_block_tokens
119        );
120    }
121
122    #[test]
123    fn total_is_sum_of_parts() {
124        let o = ContextOverhead {
125            tool_count: 10,
126            tool_schema_tokens: 100,
127            instruction_tokens: 200,
128            rules_block_tokens: 50,
129        };
130        assert_eq!(o.total_tokens(), 350);
131    }
132
133    #[test]
134    fn rules_injection_off_zeroes_the_rules_block() {
135        // With rules injection off, no rules file is written, so the per-turn
136        // overhead must not count the rules block (#361). The tool/instruction
137        // surface is unaffected. `isolated_data_dir` holds the env lock.
138        let _iso = crate::core::data_dir::isolated_data_dir();
139        let on = ContextOverhead::measure();
140        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
141        let off = ContextOverhead::measure();
142        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
143
144        assert!(on.rules_block_tokens > 0, "default still injects rules");
145        assert_eq!(off.rules_block_tokens, 0, "off must drop the rules block");
146        assert_eq!(
147            off.total_tokens(),
148            off.tool_schema_tokens + off.instruction_tokens,
149            "off total excludes the rules block"
150        );
151    }
152
153    #[test]
154    fn minimal_arm_per_turn_prefix_stays_within_budget() {
155        // The "faithful arm" (#361): tool_profile=minimal (6 tools) +
156        // LEAN_CTX_MINIMAL (no session/knowledge prefix) + rules_injection=off
157        // (no rules block) must keep the fixed per-turn prefix tiny. This is the
158        // regression guard for the "~3K tokens/turn injected" critique — if any
159        // knob silently stops applying, the total balloons and this fails.
160        const MINIMAL_ARM_PREFIX_BUDGET_TOKENS: usize = 1500;
161
162        let _iso = crate::core::data_dir::isolated_data_dir();
163        crate::test_env::set_var("LEAN_CTX_TOOL_PROFILE", "minimal");
164        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
165        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
166        let o = ContextOverhead::measure();
167        crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE");
168        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
169        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
170
171        assert_eq!(
172            o.rules_block_tokens, 0,
173            "rules_injection=off must zero the rules block"
174        );
175        assert!(
176            o.tool_count <= 8,
177            "minimal profile must keep the surface lean, got {} tools",
178            o.tool_count
179        );
180        assert!(
181            o.total_tokens() <= MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
182            "minimal-arm per-turn prefix = {} tok (schemas {} + instr {} + rules {}), budget {}",
183            o.total_tokens(),
184            o.tool_schema_tokens,
185            o.instruction_tokens,
186            o.rules_block_tokens,
187            MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
188        );
189    }
190}