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        Self::measure_for_client("")
81    }
82
83    /// [`Self::measure`] with the instructions a named MCP client receives
84    /// (`clientInfo.name`, e.g. `claude-code`): hook-covered clients and
85    /// clients with an instruction cap get a different block.
86    #[must_use]
87    pub fn measure_for_client(client_name: &str) -> Self {
88        let tools = crate::core::tool_visibility::advertised_tool_defs_default();
89        let tool_count = tools.len();
90        let tool_schema_tokens = tools.iter().map(tool_tokens).sum();
91
92        let instructions = crate::instructions::build_instructions_with_client(
93            crate::core::protocol::CrpMode::effective(),
94            client_name,
95        );
96        let instruction_tokens = count_tokens(&instructions);
97
98        // The rules block only rides every turn when lean-ctx actually injects it
99        // into the host instruction file. With `rules_injection = off` no file is
100        // written (the `rules_inject` injectors early-return), so it adds zero
101        // per-turn overhead — counting it would overstate the faithful-arm tax and
102        // make the net-of-injection figure pessimistic (#361).
103        let rules_block_tokens = if crate::core::config::Config::load().rules_injection_effective()
104            == crate::core::config::RulesInjection::Off
105        {
106            0
107        } else {
108            count_tokens(&crate::rules_inject::canonical_rules_block())
109        };
110
111        Self {
112            tool_count,
113            tool_schema_tokens,
114            instruction_tokens,
115            rules_block_tokens,
116        }
117    }
118}
119
120impl ContextOverhead {
121    /// Per-turn prefix of the Claude Code governor setup (governor
122    /// design v1, slice 1). Its hooks emit no session or per-prompt text, so
123    /// the prefix is the rule block plus, in the MCP arm, one deferred-tool
124    /// name line per tool: initialize instructions are suppressed
125    /// (`LEAN_CTX_RULES_INJECTION=off` on the server) and schemas stay behind
126    /// Tool Search until fetched (`alwaysLoad: false`). The name-line cost
127    /// models Claude Code's deferred-tool listing [inference, bench-verified].
128    #[must_use]
129    pub fn measure_governor(arm: crate::core::claude_host::GovernorArm) -> Self {
130        use crate::core::claude_host::{GovernorArm, rules_block};
131        let rules_block_tokens = rules_block(arm).map_or(0, count_tokens);
132        let (tool_count, tool_schema_tokens) = match arm {
133            GovernorArm::NoMcp => (0, 0),
134            GovernorArm::McpDeferred => {
135                let tools = crate::core::tool_visibility::advertised_tool_defs_default();
136                let names: Vec<String> = tools
137                    .iter()
138                    .map(|t| format!("mcp__lean-ctx__{}", t.name))
139                    .collect();
140                (tools.len(), count_tokens(&names.join("\n")))
141            }
142        };
143        Self {
144            tool_count,
145            tool_schema_tokens,
146            instruction_tokens: 0,
147            rules_block_tokens,
148        }
149    }
150}
151
152/// Description + input-schema tokens for one tool definition — exactly the two
153/// fields a client re-sends in every request's tool list.
154pub fn tool_tokens(t: &rmcp::model::Tool) -> usize {
155    let desc = t
156        .description
157        .as_ref()
158        .map_or(0, |d| count_tokens(d.as_ref()));
159    let schema = count_tokens(&serde_json::to_string(&t.input_schema).unwrap_or_default());
160    desc + schema
161}
162
163/// Estimated per-turn overhead of native IDE tools (Read, Grep, Shell, Glob,
164/// Write, StrReplace). lean-ctx stands in for them only where they are denied
165/// (Replace mode); see [`native_baseline_tokens_per_turn`].
166pub const NATIVE_BASELINE_TOKENS_PER_TURN: u64 = 2400;
167
168/// The native prefix lean-ctx's own prefix replaces in the active hook mode.
169/// Only Replace denies the native tools; in Hybrid and MCP mode they stay in
170/// the host's tool list and lean-ctx's prefix adds to theirs, so nothing is
171/// subtracted.
172#[must_use]
173pub fn native_baseline_tokens_per_turn() -> u64 {
174    if crate::core::hook_mode::replace_mode_chosen() {
175        NATIVE_BASELINE_TOKENS_PER_TURN
176    } else {
177        0
178    }
179}
180
181/// Price of a cached prefix token relative to base input: a cache hit reads at
182/// 0.1× and a miss writes at 1.25× (5-minute TTL) on Anthropic and on OpenAI
183/// GPT-5.6+. Not modelled: cheaper reads on some models (0.05× on Claude Opus
184/// and Sonnet 5.5) and longer TTLs (Anthropic 1 h at 2×, OpenAI 12 h at 2.5×).
185const CACHE_READ_MULTIPLIER: f64 = 0.1;
186const CACHE_WRITE_MULTIPLIER: f64 = 1.25;
187
188/// Share of turns whose prefix the provider serves from its cache. An estimate
189/// (default 75%), set with `dashboard_cache_hit_rate`.
190#[must_use]
191pub fn provider_cache_hit_rate() -> f64 {
192    crate::core::config::Config::load()
193        .dashboard_cache_hit_rate()
194        .unwrap_or(0.75)
195}
196
197/// What one prefix token costs per turn, in base-input tokens: hits read at
198/// `CACHE_READ_MULTIPLIER`, misses write at `CACHE_WRITE_MULTIPLIER`.
199/// `--no-cache-adjust` prices a provider without caching (1.0, #1104).
200#[must_use]
201pub fn prefix_cost_factor() -> f64 {
202    if no_cache_adjust_active() {
203        return 1.0;
204    }
205    cost_factor_at(provider_cache_hit_rate())
206}
207
208fn cost_factor_at(hit_rate: f64) -> f64 {
209    let hit = hit_rate.clamp(0.0, 1.0);
210    hit * CACHE_READ_MULTIPLIER + (1.0 - hit) * CACHE_WRITE_MULTIPLIER
211}
212
213// #1104: `--no-cache-adjust` prices the prefix without caching (worst-case view).
214std::thread_local! {
215    static NO_CACHE_ADJUST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
216}
217
218pub fn set_no_cache_adjust(v: bool) {
219    NO_CACHE_ADJUST.with(|c| c.set(v));
220}
221
222fn no_cache_adjust_active() -> bool {
223    NO_CACHE_ADJUST.with(std::cell::Cell::get)
224}
225
226/// Net-of-injection reconciliation with baseline + cache corrections (#1104).
227///
228/// Two corrections over the original `saved - overhead × turns`:
229/// 1. **Baseline**: in Replace mode the denied native tools' ~2,400 tok/turn of
230///    schemas leave the prefix, so only the delta above them is lean-ctx's.
231/// 2. **Cache**: the prefix bills at [`prefix_cost_factor`] per turn — cache
232///    reads are cheap, not free, and misses carry the write premium.
233#[must_use]
234pub fn net_of_injection(tokens_saved: u64, overhead_per_turn: u64, turns: u64) -> (u64, i64) {
235    net_of_injection_with(
236        tokens_saved,
237        overhead_per_turn,
238        turns,
239        native_baseline_tokens_per_turn(),
240        prefix_cost_factor(),
241    )
242}
243
244fn net_of_injection_with(
245    tokens_saved: u64,
246    overhead_per_turn: u64,
247    turns: u64,
248    native_baseline: u64,
249    cost_factor: f64,
250) -> (u64, i64) {
251    let delta = overhead_per_turn.saturating_sub(native_baseline);
252    let effective_per_turn = (delta as f64 * cost_factor) as u64;
253    let total = effective_per_turn.saturating_mul(turns);
254    let net = tokens_saved as i64 - total as i64;
255    (total, net)
256}
257
258/// Provider turns (requests) the proxy actually observed carrying the injected
259/// prefix. The proxy is the only component that sees every provider turn, so its
260/// persisted request count is the honest multiplier for the per-turn injection
261/// tax. `0` when the proxy is not in the request path — we never guess turns we
262/// did not see, so [`net_of_injection`] then collapses to the gross savings.
263#[must_use]
264pub fn observed_turns() -> u64 {
265    crate::core::proxy_metrics::load_persisted().map_or(0, |m| m.requests_total)
266}
267
268#[cfg(test)]
269pub mod tests {
270    use super::*;
271
272    #[test]
273    fn total_is_sum_of_parts() {
274        let o = ContextOverhead {
275            tool_count: 10,
276            tool_schema_tokens: 100,
277            instruction_tokens: 200,
278            rules_block_tokens: 50,
279        };
280        assert_eq!(o.total_tokens(), 350);
281    }
282
283    #[test]
284    fn rules_injection_off_zeroes_the_rules_block() {
285        // With rules injection off, no rules file is written, so the per-turn
286        // overhead must not count the rules block (#361). The tool/instruction
287        // surface is unaffected. `isolated_data_dir` holds the env lock.
288        let _iso = crate::core::data_dir::isolated_data_dir();
289        let on = ContextOverhead::measure();
290        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
291        let off = ContextOverhead::measure();
292        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
293
294        assert!(on.rules_block_tokens > 0, "default still injects rules");
295        assert_eq!(off.rules_block_tokens, 0, "off must drop the rules block");
296        assert_eq!(
297            off.total_tokens(),
298            off.tool_schema_tokens + off.instruction_tokens,
299            "off total excludes the rules block"
300        );
301    }
302
303    #[test]
304    fn minimal_arm_per_turn_prefix_stays_within_budget() {
305        // The "faithful arm" (#361): tool_profile=minimal (5 tools) +
306        // LEAN_CTX_MINIMAL (no session/knowledge prefix) + rules_injection=off
307        // (no rules block) must keep the fixed per-turn prefix tiny. This is the
308        // regression guard for the "~3K tokens/turn injected" critique — if any
309        // knob silently stops applying, the total balloons and this fails.
310        // macOS/Linux baseline is ~1829 after three reviewed additions: the v3
311        // agent-loop + navigation-paradox one-liner (#609, always-on in the COMPACT
312        // skeleton), the ctx_search `handle` param (#608), and the proactive
313        // `RECOVER` recovery one-liner + the `ctx_read` `raw` schema param
314        // (premium-recovery-layer) — together ~+39 tok over the prior ~1790. Windows
315        // additionally carries `build_shell_hint()` — a ~5-tok PowerShell-cmdlet
316        // warning that is empty on POSIX. The budget covers that surface plus a small
317        // margin for `shell_name()` variance (#1051), and still sits ~1.1K under the
318        // ~3K balloon this guard exists to catch — it is not a license for silent creep.
319        // 3.12.0: +5 for the `ctx_read` `raw` note "redaction still applies" (#2139), a
320        // security statement agents must see; the prefix measured 1966 on the largest platform.
321        const MINIMAL_ARM_PREFIX_BUDGET_TOKENS: usize = 1970;
322
323        let _iso = crate::core::data_dir::isolated_data_dir();
324        crate::test_env::set_var("LEAN_CTX_TOOL_PROFILE", "minimal");
325        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
326        crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
327        let o = ContextOverhead::measure();
328        crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE");
329        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
330        crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
331
332        assert_eq!(
333            o.rules_block_tokens, 0,
334            "rules_injection=off must zero the rules block"
335        );
336        assert!(
337            o.tool_count <= crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
338            "minimal profile must keep the surface lean, got {} tools (expected ≤ {})",
339            o.tool_count,
340            crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
341        );
342        // #1651: enforce the largest platform, not the running one. The shell
343        // hint is empty on POSIX and not on Windows, so measuring only where
344        // you happen to sit answers a different question than CI asks. #1646
345        // spent three CI round-trips on an edit that measured 1953 on macOS and
346        // failed at 1968 on Windows; the CHANGELOG records the same trap in
347        // #1625. A local run now reports the number CI will enforce.
348        let windows_only = if cfg!(windows) {
349            0
350        } else {
351            crate::instructions::max_shell_hint_tokens()
352        };
353        let worst_case = o.total_tokens() + windows_only;
354
355        assert!(
356            worst_case <= MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
357            "minimal-arm per-turn prefix = {worst_case} tok on the largest platform \
358             (here {} = schemas {} + instr {} + rules {}, plus {windows_only} for the \
359             Windows-only shell hint), budget {}.\n\
360             Editing a `tool_def` description also changes \
361             docs/reference/generated/mcp-tools.md — re-run \
362             `cargo run -p lean-ctx-server --example gen_docs --features dev-tools` or the Documentation \
363             job fails too. Neither consequence is visible from the diff.",
364            o.total_tokens(),
365            o.tool_schema_tokens,
366            o.instruction_tokens,
367            o.rules_block_tokens,
368            MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
369        );
370    }
371
372    #[test]
373    fn default_arm_per_turn_prefix_stays_within_budget_per_client() {
374        // The standard MCP arm: no pinned profile (lazy core), default
375        // instructions, rules injection on. Every non-Claude host gets it; on
376        // Claude Code it is the explicit `--mode hybrid` choice since the
377        // governor became the default there (owner 2026-11-01; guarded by
378        // `governor_arm_per_turn_prefix_stays_within_budget`). The minimal-arm guard above never
379        // saw this surface, so the default could grow unnoticed (Optimierungsplan
380        // P1). Budgets are the measured 3.12.0 baseline plus a small margin, on
381        // the largest platform; lowering them is the goal, raising them needs a
382        // reason in the commit.
383        // 3.12.0 baseline: claude-code 2969, codex 2770, cursor 3248 (12 tools,
384        // schemas 2236, rules 248; instructions 468 / 269 / 747), measured
385        // against the developer's real HOME. A3 (one rules channel, each client
386        // with its `setup` carrier in a temp HOME, the anchor path, no per-turn
387        // solution ladder): claude-code 2548, codex 2548, cursor 2499
388        // (instructions 112 / 112 / 63, rules 183).
389        const BUDGETS: &[(&str, usize)] = &[
390            ("claude-code", 2560),
391            ("codex-mcp-client", 2560),
392            ("cursor-vscode", 2510),
393        ];
394
395        let iso = crate::core::data_dir::isolated_data_dir();
396        // A3: what a host is billed depends on its installed rules carrier —
397        // with the carrier holding the block the instructions collapse to an
398        // anchor (one rules channel). Measuring against the developer's real
399        // HOME made the numbers depend on that machine; each client now gets a
400        // temp HOME with exactly the carrier `setup` writes for it.
401        let saved: Vec<_> = ["HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME"]
402            .iter()
403            .map(|k| (*k, std::env::var_os(k)))
404            .collect();
405        crate::test_env::remove_var("CLAUDE_CONFIG_DIR");
406        crate::test_env::remove_var("CODEX_HOME");
407        let install_carrier = |client: &str, home: &std::path::Path| {
408            let target = match client {
409                "claude-code" => {
410                    crate::core::claude_md::sync_claude_global_rules_block(
411                        home,
412                        crate::core::hook_mode::recommend_hook_mode("claude"),
413                    )
414                    .expect("claude block");
415                    return;
416                }
417                "codex-mcp-client" => ("Codex CLI", ".codex/instructions.md"),
418                _ => ("Cursor", ".cursor/rules/lean-ctx.mdc"),
419            };
420            let path = home.join(target.1);
421            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
422            let block = crate::rules_inject::expected_blocks_by_target(home)
423                .remove(target.0)
424                .unwrap();
425            std::fs::write(path, block).unwrap();
426        };
427        let windows_only = if cfg!(windows) {
428            0
429        } else {
430            crate::instructions::max_shell_hint_tokens()
431        };
432        let over: Vec<String> = BUDGETS
433            .iter()
434            .filter_map(|&(client, budget)| {
435                let home = iso.path().join(format!("home-{client}"));
436                std::fs::create_dir_all(&home).unwrap();
437                crate::test_env::set_var("HOME", &home);
438                install_carrier(client, &home);
439                let o = ContextOverhead::measure_for_client(client);
440                let worst_case = o.total_tokens() + windows_only;
441                eprintln!(
442                    "{client}: {worst_case} tok (schemas {} + instr {} + rules {} + {windows_only})",
443                    o.tool_schema_tokens, o.instruction_tokens, o.rules_block_tokens
444                );
445                (worst_case > budget).then(|| {
446                    format!(
447                        "{client}: {worst_case} tok on the largest platform ({} tools: schemas {} \
448                         + instr {} + rules {} + {windows_only} Windows-only), budget {budget}",
449                        o.tool_count,
450                        o.tool_schema_tokens,
451                        o.instruction_tokens,
452                        o.rules_block_tokens,
453                    )
454                })
455            })
456            .collect();
457        for (k, v) in saved {
458            match v {
459                Some(v) => crate::test_env::set_var(k, v),
460                None => crate::test_env::remove_var(k),
461            }
462        }
463        assert!(
464            over.is_empty(),
465            "default-arm per-turn prefix over budget:\n{}",
466            over.join("\n")
467        );
468    }
469
470    #[test]
471    fn cached_prefix_is_cheap_not_free_and_misses_pay_the_write_premium() {
472        // 75% hits: 0.75 × 0.1 + 0.25 × 1.25 = 0.3875 per prefix token.
473        assert!((cost_factor_at(0.75) - 0.3875).abs() < 1e-12);
474        assert!(cost_factor_at(1.0) > 0.0, "a warm cache still bills reads");
475        assert!(cost_factor_at(0.0) > 1.0, "a cold cache bills the write");
476        // Replace: 3400 − 2400 = 1000 delta → 387/turn → 8 turns = 3096.
477        assert_eq!(
478            net_of_injection_with(10_000, 3400, 8, NATIVE_BASELINE_TOKENS_PER_TURN, 0.3875),
479            (3096, 6904)
480        );
481        // Hybrid/MCP keep the native tools: the whole prefix is lean-ctx's.
482        assert_eq!(
483            net_of_injection_with(10_000, 3400, 8, 0, 0.3875),
484            (10_536, -536)
485        );
486    }
487
488    #[test]
489    fn only_replace_mode_subtracts_the_native_baseline() {
490        let _iso = crate::core::data_dir::isolated_data_dir();
491        let mut seen = Vec::new();
492        for mode in ["", "hybrid", "mcp", "replace"] {
493            if mode.is_empty() {
494                crate::test_env::remove_var("LEAN_CTX_HOOK_MODE");
495            } else {
496                crate::test_env::set_var("LEAN_CTX_HOOK_MODE", mode);
497            }
498            seen.push((mode, native_baseline_tokens_per_turn()));
499        }
500        crate::test_env::remove_var("LEAN_CTX_HOOK_MODE");
501        assert_eq!(
502            seen,
503            [
504                ("", 0),
505                ("hybrid", 0),
506                ("mcp", 0),
507                ("replace", NATIVE_BASELINE_TOKENS_PER_TURN)
508            ]
509        );
510    }
511
512    #[test]
513    fn net_of_injection_collapses_to_gross_without_proxy_turns() {
514        assert_eq!(net_of_injection(1234, 3000, 0), (0, 1234));
515    }
516
517    /// `PROACTIVE_INJECTED_TOKENS` is a single process-global counter, so the
518    /// two tests below raced under `cargo test`'s parallelism: one reset and
519    /// added while the other reset and read, and the reader observed this
520    /// module's own `17 + 5`. The failure value was literally `22`, which is
521    /// what made the source unambiguous.
522    ///
523    /// A lock scoped to this pair is the right size for the problem —
524    /// `test_env_lock` would serialize them against every env-mutating test in
525    /// the suite to protect one `AtomicUsize`.
526    static COUNTER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
527
528    #[test]
529    fn proactive_injection_counter_accumulates() {
530        let _guard = COUNTER_LOCK
531            .lock()
532            .unwrap_or_else(std::sync::PoisonError::into_inner);
533        reset_proactive_injection();
534        record_proactive_injection(17);
535        record_proactive_injection(5);
536        assert_eq!(proactive_injected_tokens(), 22);
537    }
538
539    #[test]
540    fn proactive_injection_counter_starts_at_zero_after_reset() {
541        let _guard = COUNTER_LOCK
542            .lock()
543            .unwrap_or_else(std::sync::PoisonError::into_inner);
544        reset_proactive_injection();
545        assert_eq!(proactive_injected_tokens(), 0);
546    }
547}