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/// Pure net-of-injection reconciliation: the total injection tax
102/// (`overhead_per_turn × turns`) and the signed net savings after subtracting
103/// it. Lives here — the home of injection accounting — so both `lean-ctx gain`
104/// and the verified savings ledger/ROI reconcile against the same math. The net
105/// is signed because on a non-caching rail a short run can legitimately go
106/// net-negative until savings outgrow the per-turn injection (#361, #685).
107#[must_use]
108pub fn net_of_injection(tokens_saved: u64, overhead_per_turn: u64, turns: u64) -> (u64, i64) {
109 let total = overhead_per_turn.saturating_mul(turns);
110 let net = tokens_saved as i64 - total as i64;
111 (total, net)
112}
113
114/// Provider turns (requests) the proxy actually observed carrying the injected
115/// prefix. The proxy is the only component that sees every provider turn, so its
116/// persisted request count is the honest multiplier for the per-turn injection
117/// tax. `0` when the proxy is not in the request path — we never guess turns we
118/// did not see, so [`net_of_injection`] then collapses to the gross savings.
119#[must_use]
120pub fn observed_turns() -> u64 {
121 crate::proxy::metrics::load_persisted().map_or(0, |m| m.requests_total)
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn measure_reports_nonzero_components() {
130 // Isolated (default) config: shared rules injection, no pinned profile —
131 // every component carries tokens. `isolated_data_dir` also holds the env
132 // lock, so a concurrent test toggling the knobs can't perturb this.
133 let _iso = crate::core::data_dir::isolated_data_dir();
134 let o = ContextOverhead::measure();
135 assert!(o.tool_count > 0, "must expose at least one tool");
136 assert!(o.tool_schema_tokens > 0, "tool schemas carry tokens");
137 assert!(o.instruction_tokens > 0, "instructions carry tokens");
138 assert!(o.rules_block_tokens > 0, "rules block carries tokens");
139 assert_eq!(
140 o.total_tokens(),
141 o.tool_schema_tokens + o.instruction_tokens + o.rules_block_tokens
142 );
143 }
144
145 #[test]
146 fn total_is_sum_of_parts() {
147 let o = ContextOverhead {
148 tool_count: 10,
149 tool_schema_tokens: 100,
150 instruction_tokens: 200,
151 rules_block_tokens: 50,
152 };
153 assert_eq!(o.total_tokens(), 350);
154 }
155
156 #[test]
157 fn rules_injection_off_zeroes_the_rules_block() {
158 // With rules injection off, no rules file is written, so the per-turn
159 // overhead must not count the rules block (#361). The tool/instruction
160 // surface is unaffected. `isolated_data_dir` holds the env lock.
161 let _iso = crate::core::data_dir::isolated_data_dir();
162 let on = ContextOverhead::measure();
163 crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
164 let off = ContextOverhead::measure();
165 crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
166
167 assert!(on.rules_block_tokens > 0, "default still injects rules");
168 assert_eq!(off.rules_block_tokens, 0, "off must drop the rules block");
169 assert_eq!(
170 off.total_tokens(),
171 off.tool_schema_tokens + off.instruction_tokens,
172 "off total excludes the rules block"
173 );
174 }
175
176 #[test]
177 fn minimal_arm_per_turn_prefix_stays_within_budget() {
178 // The "faithful arm" (#361): tool_profile=minimal (5 tools) +
179 // LEAN_CTX_MINIMAL (no session/knowledge prefix) + rules_injection=off
180 // (no rules block) must keep the fixed per-turn prefix tiny. This is the
181 // regression guard for the "~3K tokens/turn injected" critique — if any
182 // knob silently stops applying, the total balloons and this fails.
183 // macOS/Linux baseline is ~1829 after three reviewed additions: the v3
184 // agent-loop + navigation-paradox one-liner (#609, always-on in the COMPACT
185 // skeleton), the ctx_search `handle` param (#608), and the proactive
186 // `RECOVER` recovery one-liner + the `ctx_read` `raw` schema param
187 // (premium-recovery-layer) — together ~+39 tok over the prior ~1790. Windows
188 // additionally carries `build_shell_hint()` — a ~5-tok PowerShell-cmdlet
189 // warning that is empty on POSIX. The budget covers that surface plus a small
190 // margin for `shell_name()` variance (#1051), and still sits ~1.1K under the
191 // ~3K balloon this guard exists to catch — it is not a license for silent creep.
192 const MINIMAL_ARM_PREFIX_BUDGET_TOKENS: usize = 1835;
193
194 let _iso = crate::core::data_dir::isolated_data_dir();
195 crate::test_env::set_var("LEAN_CTX_TOOL_PROFILE", "minimal");
196 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
197 crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "off");
198 let o = ContextOverhead::measure();
199 crate::test_env::remove_var("LEAN_CTX_TOOL_PROFILE");
200 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
201 crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
202
203 assert_eq!(
204 o.rules_block_tokens, 0,
205 "rules_injection=off must zero the rules block"
206 );
207 assert!(
208 o.tool_count <= crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
209 "minimal profile must keep the surface lean, got {} tools (expected ≤ {})",
210 o.tool_count,
211 crate::core::tool_profiles::ToolProfile::Minimal.tool_count() + 1,
212 );
213 assert!(
214 o.total_tokens() <= MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
215 "minimal-arm per-turn prefix = {} tok (schemas {} + instr {} + rules {}), budget {}",
216 o.total_tokens(),
217 o.tool_schema_tokens,
218 o.instruction_tokens,
219 o.rules_block_tokens,
220 MINIMAL_ARM_PREFIX_BUDGET_TOKENS,
221 );
222 }
223
224 #[test]
225 fn net_of_injection_subtracts_per_turn_tax() {
226 // 1000 saved, 50/turn over 8 turns = 400 tax → net 600.
227 assert_eq!(net_of_injection(1000, 50, 8), (400, 600));
228 }
229
230 #[test]
231 fn net_of_injection_can_go_negative_on_short_runs() {
232 // The honest case the report must not hide: gross < injection tax.
233 assert_eq!(net_of_injection(100, 50, 8), (400, -300));
234 }
235
236 #[test]
237 fn net_of_injection_collapses_to_gross_without_proxy_turns() {
238 // No proxy in the path → no counted turns → net == gross.
239 assert_eq!(net_of_injection(1234, 3000, 0), (0, 1234));
240 }
241}