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;
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22use crate::core::tokens::count_tokens;
23
24static PROACTIVE_INJECTED_TOKENS: AtomicUsize = AtomicUsize::new(0);
25
26/// Record tokens appended as proactive context so savings reports can account
27/// for the dynamic response-side injection separately from fixed overhead.
28pub fn record_proactive_injection(tokens: usize) {
29    PROACTIVE_INJECTED_TOKENS.fetch_add(tokens, Ordering::Relaxed);
30}
31
32/// Total proactive context tokens appended by this process.
33#[must_use]
34pub fn proactive_injected_tokens() -> usize {
35    PROACTIVE_INJECTED_TOKENS.load(Ordering::Relaxed)
36}
37
38#[cfg(test)]
39fn reset_proactive_injection() {
40    PROACTIVE_INJECTED_TOKENS.store(0, Ordering::Relaxed);
41}
42
43/// A measured breakdown, in tokens, of the per-turn context lean-ctx adds.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub struct ContextOverhead {
46    /// Number of MCP tools exposed (the schema-bearing surface).
47    pub tool_count: usize,
48    /// Tokens for all exposed tool descriptions + input schemas.
49    pub tool_schema_tokens: usize,
50    /// Tokens for the MCP server instructions block (capped at the instruction budget).
51    pub instruction_tokens: usize,
52    /// Tokens for the rules block injected into the host instruction file.
53    pub rules_block_tokens: usize,
54}
55
56impl ContextOverhead {
57    /// Total per-turn overhead in tokens.
58    #[must_use]
59    pub fn total_tokens(&self) -> usize {
60        self.tool_schema_tokens + self.instruction_tokens + self.rules_block_tokens
61    }
62
63    /// Process-cached overhead. The tool surface and rules block are static and
64    /// the instruction block varies only with slow-moving session state, so a
65    /// once-per-process measurement is the right tradeoff for callers that render
66    /// repeatedly (the `gain` dashboard re-renders every second in `--live`) —
67    /// it avoids per-tick disk I/O and re-tokenization.
68    #[must_use]
69    pub fn cached() -> Self {
70        static CACHE: OnceLock<ContextOverhead> = OnceLock::new();
71        *CACHE.get_or_init(Self::measure)
72    }
73
74    /// Measure the overhead for the currently-configured MCP surface. Uses the
75    /// same advertisement policy as the live `tools/list` handler (candidate
76    /// set, profile gates, invoker, description compression), so the number
77    /// reflects what this install actually advertises (#572).
78    #[must_use]
79    pub fn measure() -> Self {
80        let tools = crate::server::tool_visibility::advertised_tool_defs_default();
81        let tool_count = tools.len();
82        let tool_schema_tokens = tools.iter().map(tool_tokens).sum();
83
84        let instructions =
85            crate::instructions::build_instructions(crate::tools::CrpMode::effective());
86        let instruction_tokens = count_tokens(&instructions);
87
88        // The rules block only rides every turn when lean-ctx actually injects it
89        // into the host instruction file. With `rules_injection = off` no file is
90        // written (the `rules_inject` injectors early-return), so it adds zero
91        // per-turn overhead — counting it would overstate the faithful-arm tax and
92        // make the net-of-injection figure pessimistic (#361).
93        let rules_block_tokens = if crate::core::config::Config::load().rules_injection_effective()
94            == crate::core::config::RulesInjection::Off
95        {
96            0
97        } else {
98            count_tokens(&crate::rules_inject::canonical_rules_block())
99        };
100
101        Self {
102            tool_count,
103            tool_schema_tokens,
104            instruction_tokens,
105            rules_block_tokens,
106        }
107    }
108}
109
110/// Description + input-schema tokens for one tool definition — exactly the two
111/// fields a client re-sends in every request's tool list.
112pub fn tool_tokens(t: &rmcp::model::Tool) -> usize {
113    let desc = t
114        .description
115        .as_ref()
116        .map_or(0, |d| count_tokens(d.as_ref()));
117    let schema = count_tokens(&serde_json::to_string(&t.input_schema).unwrap_or_default());
118    desc + schema
119}
120
121/// Estimated per-turn overhead of native IDE tools (Read, Grep, Shell, Glob,
122/// Write, StrReplace) — lean-ctx replaces these 1:1, so only the delta above
123/// this baseline is attributable lean-ctx overhead.
124pub(crate) const NATIVE_BASELINE_TOKENS_PER_TURN: u64 = 2400;
125
126/// Conservative provider prompt-cache hit rate. Anthropic achieves ~90% on
127/// stable prefixes (#498), OpenAI ~50%. Default 75% cross-provider estimate.
128/// Returns 0.0 when `--no-cache-adjust` is active (#1104).
129fn provider_cache_hit_rate() -> f64 {
130    if no_cache_adjust_active() {
131        return 0.0;
132    }
133    crate::core::config::Config::load()
134        .dashboard_cache_hit_rate()
135        .unwrap_or(0.75)
136}
137
138// #1104: `--no-cache-adjust` forces cache_rate=0 (worst-case view).
139std::thread_local! {
140    static NO_CACHE_ADJUST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
141}
142
143pub fn set_no_cache_adjust(v: bool) {
144    NO_CACHE_ADJUST.with(|c| c.set(v));
145}
146
147fn no_cache_adjust_active() -> bool {
148    NO_CACHE_ADJUST.with(std::cell::Cell::get)
149}
150
151/// Net-of-injection reconciliation with baseline + cache corrections (#1104).
152///
153/// Two fixes over the original `saved - overhead × turns`:
154/// 1. **Baseline**: native tools also inject ~2,400 tok/turn of schemas — only
155///    the delta above that is lean-ctx's fault.
156/// 2. **Cache**: stable prefixes are cached by providers (Anthropic 90%,
157///    OpenAI 50%) — effective cost is `delta × (1 - cache_rate)`.
158#[must_use]
159pub fn net_of_injection(tokens_saved: u64, overhead_per_turn: u64, turns: u64) -> (u64, i64) {
160    let delta = overhead_per_turn.saturating_sub(NATIVE_BASELINE_TOKENS_PER_TURN);
161    let cache_rate = provider_cache_hit_rate();
162    let effective_per_turn = (delta as f64 * (1.0 - cache_rate)) as u64;
163    let total = effective_per_turn.saturating_mul(turns);
164    let net = tokens_saved as i64 - total as i64;
165    (total, net)
166}
167
168/// Provider turns (requests) the proxy actually observed carrying the injected
169/// prefix. The proxy is the only component that sees every provider turn, so its
170/// persisted request count is the honest multiplier for the per-turn injection
171/// tax. `0` when the proxy is not in the request path — we never guess turns we
172/// did not see, so [`net_of_injection`] then collapses to the gross savings.
173#[must_use]
174pub fn observed_turns() -> u64 {
175    crate::proxy::metrics::load_persisted().map_or(0, |m| m.requests_total)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn measure_reports_nonzero_components() {
184        // Isolated (default) config: shared rules injection, no pinned profile —
185        // every component carries tokens. `isolated_data_dir` also holds the env
186        // lock, so a concurrent test toggling the knobs can't perturb this.
187        let _iso = crate::core::data_dir::isolated_data_dir();
188        let o = ContextOverhead::measure();
189        assert!(o.tool_count > 0, "must expose at least one tool");
190        assert!(o.tool_schema_tokens > 0, "tool schemas carry tokens");
191        assert!(o.instruction_tokens > 0, "instructions carry tokens");
192        assert!(o.rules_block_tokens > 0, "rules block carries tokens");
193        assert_eq!(
194            o.total_tokens(),
195            o.tool_schema_tokens + o.instruction_tokens + o.rules_block_tokens
196        );
197    }
198
199    #[test]
200    fn total_is_sum_of_parts() {
201        let o = ContextOverhead {
202            tool_count: 10,
203            tool_schema_tokens: 100,
204            instruction_tokens: 200,
205            rules_block_tokens: 50,
206        };
207        assert_eq!(o.total_tokens(), 350);
208    }
209
210    #[test]
211    fn rules_injection_off_zeroes_the_rules_block() {
212        // With rules injection off, no rules file is written, so the per-turn
213        // overhead must not count the rules block (#361). The tool/instruction
214        // surface is unaffected. `isolated_data_dir` holds the env lock.
215        let _iso = crate::core::data_dir::isolated_data_dir();
216        let on = ContextOverhead::measure();
217        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
218        let off = ContextOverhead::measure();
219        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
220
221        assert!(on.rules_block_tokens > 0, "default still injects rules");
222        assert_eq!(off.rules_block_tokens, 0, "off must drop the rules block");
223        assert_eq!(
224            off.total_tokens(),
225            off.tool_schema_tokens + off.instruction_tokens,
226            "off total excludes the rules block"
227        );
228    }
229
230    #[test]
231    fn minimal_arm_per_turn_prefix_stays_within_budget() {
232        // The "faithful arm" (#361): tool_profile=minimal (5 tools) +
233        // LEAN_CTX_MINIMAL (no session/knowledge prefix) + rules_injection=off
234        // (no rules block) must keep the fixed per-turn prefix tiny. This is the
235        // regression guard for the "~3K tokens/turn injected" critique — if any
236        // knob silently stops applying, the total balloons and this fails.
237        // macOS/Linux baseline is ~1829 after three reviewed additions: the v3
238        // agent-loop + navigation-paradox one-liner (#609, always-on in the COMPACT
239        // skeleton), the ctx_search `handle` param (#608), and the proactive
240        // `RECOVER` recovery one-liner + the `ctx_read` `raw` schema param
241        // (premium-recovery-layer) — together ~+39 tok over the prior ~1790. Windows
242        // additionally carries `build_shell_hint()` — a ~5-tok PowerShell-cmdlet
243        // warning that is empty on POSIX. The budget covers that surface plus a small
244        // margin for `shell_name()` variance (#1051), and still sits ~1.1K under the
245        // ~3K balloon this guard exists to catch — it is not a license for silent creep.
246        const MINIMAL_ARM_PREFIX_BUDGET_TOKENS: usize = 1835;
247
248        let _iso = crate::core::data_dir::isolated_data_dir();
249        crate::test_env::set_var("LEAN_CTX_TOOL_PROFILE", "minimal");
250        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
251        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
252        let o = ContextOverhead::measure();
253        crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE");
254        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
255        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
256
257        assert_eq!(
258            o.rules_block_tokens, 0,
259            "rules_injection=off must zero the rules block"
260        );
261        assert!(
262            o.tool_count <= crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
263            "minimal profile must keep the surface lean, got {} tools (expected ≤ {})",
264            o.tool_count,
265            crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
266        );
267        assert!(
268            o.total_tokens() <= MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
269            "minimal-arm per-turn prefix = {} tok (schemas {} + instr {} + rules {}), budget {}",
270            o.total_tokens(),
271            o.tool_schema_tokens,
272            o.instruction_tokens,
273            o.rules_block_tokens,
274            MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
275        );
276    }
277
278    #[test]
279    fn net_of_injection_subtracts_delta_above_baseline() {
280        // 3400 tok/turn - 2400 baseline = 1000 delta.
281        // With 75% cache: effective = 1000 * 0.25 = 250/turn.
282        // 8 turns: total = 2000, net = 10000 - 2000 = 8000.
283        let (total, net) = net_of_injection(10000, 3400, 8);
284        assert!(
285            total < 3000,
286            "cache + baseline must reduce tax, got {total}"
287        );
288        assert!(net > 7000, "net must reflect reduced tax, got {net}");
289    }
290
291    #[test]
292    fn net_of_injection_below_baseline_means_zero_tax() {
293        // 2000 tok/turn < 2400 baseline → delta = 0 → no tax at all.
294        assert_eq!(net_of_injection(5000, 2000, 100), (0, 5000));
295    }
296
297    #[test]
298    fn net_of_injection_collapses_to_gross_without_proxy_turns() {
299        assert_eq!(net_of_injection(1234, 3000, 0), (0, 1234));
300    }
301
302    #[test]
303    fn proactive_injection_counter_accumulates() {
304        reset_proactive_injection();
305        record_proactive_injection(17);
306        record_proactive_injection(5);
307        assert_eq!(proactive_injected_tokens(), 22);
308    }
309
310    #[test]
311    fn proactive_injection_counter_starts_at_zero_after_reset() {
312        reset_proactive_injection();
313        assert_eq!(proactive_injected_tokens(), 0);
314    }
315}