Skip to main content

lean_ctx/core/context_kernel/
mcp_receipt.rs

1//! Receipt recording and honest accounting for MCP tool calls.
2
3use std::collections::HashMap;
4use std::sync::{Mutex, MutexGuard, OnceLock};
5
6use super::accounting_fix::{PostDeliveryAccounting, compute_honest_accounting};
7
8/// Receipt for a single MCP tool call.
9#[derive(Debug, Clone)]
10pub struct McpReceipt {
11    /// Tool name.
12    pub tool: String,
13    /// Input tokens, including arguments and context sent to the tool.
14    pub tokens_in: usize,
15    /// Output tokens in the tool result.
16    pub tokens_out: usize,
17    /// Kernel overhead tokens added through enrichment and hints.
18    pub kernel_overhead: usize,
19    /// Whether the outcome was accepted.
20    pub accepted: bool,
21}
22
23/// Per-tool savings summary.
24#[derive(Debug, Clone, Default)]
25pub struct ToolSavings {
26    /// Tool name.
27    pub tool: String,
28    /// Number of recorded calls.
29    pub calls: usize,
30    /// Total input tokens.
31    pub tokens_in: usize,
32    /// Total output tokens.
33    pub tokens_out: usize,
34    /// Total kernel overhead tokens.
35    pub kernel_overhead: usize,
36    /// Savings after output and kernel overhead, as a percentage of input.
37    pub honest_savings_pct: f64,
38}
39
40#[derive(Default)]
41struct McpReceiptStore {
42    receipts: Vec<McpReceipt>,
43    per_tool: HashMap<String, ToolSavings>,
44}
45
46static RECEIPTS: OnceLock<Mutex<McpReceiptStore>> = OnceLock::new();
47
48fn receipt_store() -> &'static Mutex<McpReceiptStore> {
49    RECEIPTS.get_or_init(|| Mutex::new(McpReceiptStore::default()))
50}
51
52fn lock_store() -> MutexGuard<'static, McpReceiptStore> {
53    match receipt_store().lock() {
54        Ok(store) => store,
55        Err(poisoned) => poisoned.into_inner(),
56    }
57}
58
59fn savings_pct(tokens_in: usize, delivered: usize) -> f64 {
60    if tokens_in == 0 {
61        0.0
62    } else {
63        (1.0 - delivered as f64 / tokens_in as f64) * 100.0
64    }
65}
66
67/// Stores an MCP receipt and updates its per-tool aggregate.
68pub fn record_receipt(receipt: McpReceipt) {
69    let mut store = lock_store();
70    let summary = store
71        .per_tool
72        .entry(receipt.tool.clone())
73        .or_insert_with(|| ToolSavings {
74            tool: receipt.tool.clone(),
75            ..ToolSavings::default()
76        });
77    summary.calls = summary.calls.saturating_add(1);
78    summary.tokens_in = summary.tokens_in.saturating_add(receipt.tokens_in);
79    summary.tokens_out = summary.tokens_out.saturating_add(receipt.tokens_out);
80    summary.kernel_overhead = summary
81        .kernel_overhead
82        .saturating_add(receipt.kernel_overhead);
83    summary.honest_savings_pct = savings_pct(
84        summary.tokens_in,
85        summary.tokens_out.saturating_add(summary.kernel_overhead),
86    );
87    store.receipts.push(receipt);
88}
89
90/// Returns honest accounting aggregated across all MCP receipts.
91pub fn mcp_accounting() -> PostDeliveryAccounting {
92    let store = lock_store();
93    let mut totals = (0usize, 0usize, 0usize);
94    for receipt in &store.receipts {
95        totals.0 = totals.0.saturating_add(receipt.tokens_in);
96        totals.1 = totals.1.saturating_add(receipt.tokens_out);
97        totals.2 = totals.2.saturating_add(receipt.kernel_overhead);
98    }
99    compute_honest_accounting(totals.0, totals.1, totals.2, 0)
100}
101
102/// Returns per-tool savings sorted by tool name.
103pub fn per_tool_savings() -> Vec<ToolSavings> {
104    let mut summaries: Vec<_> = lock_store().per_tool.values().cloned().collect();
105    summaries.sort_unstable_by_key(|summary| summary.tool.clone());
106    summaries
107}
108
109/// Formats a deterministic per-tool savings summary.
110pub fn savings_report() -> String {
111    per_tool_savings()
112        .iter()
113        .map(|summary| {
114            format!(
115                "{}: {} calls, {:.2}% savings",
116                summary.tool, summary.calls, summary.honest_savings_pct,
117            )
118        })
119        .collect::<Vec<_>>()
120        .join("\n")
121}
122
123/// Returns the total token overhead added by the kernel.
124pub fn total_kernel_overhead() -> usize {
125    mcp_accounting().kernel_overhead_tokens
126}
127
128/// Clears all recorded MCP receipts and aggregates.
129pub fn reset_receipts() {
130    *lock_store() = McpReceiptStore::default();
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{
136        McpReceipt, mcp_accounting, per_tool_savings, record_receipt, reset_receipts,
137        savings_report, total_kernel_overhead,
138    };
139    use std::sync::{Mutex, MutexGuard};
140    static TEST_LOCK: Mutex<()> = Mutex::new(());
141    fn isolated_test() -> MutexGuard<'static, ()> {
142        let guard = match TEST_LOCK.lock() {
143            Ok(guard) => guard,
144            Err(poisoned) => poisoned.into_inner(),
145        };
146        reset_receipts();
147        guard
148    }
149    fn receipt(tool: &str) -> McpReceipt {
150        McpReceipt {
151            tool: tool.to_owned(),
152            tokens_in: 100,
153            tokens_out: 40,
154            kernel_overhead: 10,
155            accepted: true,
156        }
157    }
158    #[test]
159    fn record_and_retrieve() {
160        let _guard = isolated_test();
161        record_receipt(receipt("read"));
162        record_receipt(receipt("search"));
163        record_receipt(receipt("read"));
164        assert_eq!(per_tool_savings().len(), 2);
165    }
166    #[test]
167    fn accounting_is_honest() {
168        let _guard = isolated_test();
169        record_receipt(receipt("read"));
170        let accounting = mcp_accounting();
171        assert_eq!(accounting.kernel_overhead_tokens, 10);
172        assert!((accounting.phantom_savings_pct - 0.1).abs() < f64::EPSILON);
173    }
174    #[test]
175    fn per_tool_aggregates() {
176        let _guard = isolated_test();
177        for _ in 0..3 {
178            record_receipt(receipt("read"));
179        }
180        let summaries = per_tool_savings();
181        assert_eq!(summaries[0].calls, 3);
182        assert_eq!(summaries[0].honest_savings_pct, 50.0);
183    }
184    #[test]
185    fn savings_report_formatted() {
186        let _guard = isolated_test();
187        record_receipt(receipt("search"));
188        let report = savings_report();
189        assert!(report.contains("search"));
190        assert!(report.contains("50.00% savings"));
191    }
192    #[test]
193    fn reset_clears_all() {
194        let _guard = isolated_test();
195        record_receipt(receipt("read"));
196        reset_receipts();
197        assert!(per_tool_savings().is_empty());
198        assert_eq!(total_kernel_overhead(), 0);
199    }
200}