Skip to main content

lean_ctx/core/gain/
mod.rs

1pub mod bridge_status;
2pub mod gain_score;
3pub mod model_pricing;
4pub mod task_classifier;
5
6use serde::{Deserialize, Serialize};
7
8use crate::core::a2a::cost_attribution::CostStore;
9use crate::core::gain::gain_score::GainScore;
10use crate::core::gain::model_pricing::{ModelPricing, ModelQuote};
11use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
12use crate::core::heatmap::HeatMap;
13use crate::core::stats::StatsStore;
14
15#[derive(Clone)]
16pub struct GainEngine {
17    pub stats: StatsStore,
18    pub costs: CostStore,
19    pub heatmap: HeatMap,
20    pub pricing: ModelPricing,
21    pub events: Vec<crate::core::events::LeanCtxEvent>,
22    pub session: Option<crate::core::session::SessionState>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GainSummary {
27    pub model: ModelQuote,
28    pub total_commands: u64,
29    pub input_tokens: u64,
30    pub output_tokens: u64,
31    pub tokens_saved: u64,
32    pub gain_rate_pct: f64,
33    /// Fixed per-turn context lean-ctx injects (tool schemas + server
34    /// instructions + rules block). On a provider WITHOUT prompt caching this
35    /// rides — and is re-billed on — every turn, so the net bill impact is
36    /// `tokens_saved − injected_overhead_tokens_per_turn × turns`. Note that
37    /// `tokens_saved` / `gain_rate_pct` are measured against lean-ctx-touched
38    /// traffic (their denominator), not the full provider bill (GitHub #361).
39    #[serde(default)]
40    pub injected_overhead_tokens_per_turn: u64,
41    /// Provider turns (requests) the proxy actually saw carry the injected
42    /// prefix. `0` when the proxy is not in the request path, in which case the
43    /// net figure below collapses to the gross `tokens_saved` (we cannot count
44    /// turns we never observed, and we refuse to guess).
45    #[serde(default)]
46    pub turns: u64,
47    /// `injected_overhead_tokens_per_turn × turns` — the total fixed context tax
48    /// re-billed across the run on a provider without prompt caching.
49    #[serde(default)]
50    pub injected_overhead_total_tokens: u64,
51    /// The honest bill impact: `tokens_saved − injected_overhead_total_tokens`.
52    /// Signed, because on a non-caching rail a short run can legitimately go
53    /// net-negative until savings outgrow the per-turn injection.
54    #[serde(default)]
55    pub net_tokens_saved: i64,
56    pub avoided_usd: f64,
57    /// Estimated grid energy avoided (Wh) by keeping `tokens_saved` out of context.
58    pub energy_wh: f64,
59    /// Estimated CO₂-equivalent avoided (grams), derived from `energy_wh`.
60    pub co2_grams: f64,
61    pub tool_spend_usd: f64,
62    pub roi: Option<f64>,
63    pub score: GainScore,
64    #[serde(skip_serializing_if = "Option::is_none", default)]
65    pub daemon_hint: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TaskGainRow {
70    pub category: TaskCategory,
71    pub commands: u64,
72    pub tokens_saved: u64,
73    pub tool_calls: u64,
74    pub tool_spend_usd: f64,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct FileGainRow {
79    pub path: String,
80    pub access_count: u32,
81    pub tokens_saved: u64,
82    pub compression_pct: f32,
83}
84
85/// Pure net-of-injection reconciliation: the total injection tax
86/// (`overhead_per_turn × turns`) and the signed net savings after subtracting
87/// it. Extracted so the bill-reconciliation math is unit-testable without the
88/// global proxy/stats state that [`GainEngine::summary`] reads.
89pub(crate) fn net_of_injection(
90    tokens_saved: u64,
91    overhead_per_turn: u64,
92    turns: u64,
93) -> (u64, i64) {
94    let total = overhead_per_turn.saturating_mul(turns);
95    let net = tokens_saved as i64 - total as i64;
96    (total, net)
97}
98
99impl GainEngine {
100    pub fn load() -> Self {
101        Self {
102            stats: crate::core::stats::load(),
103            costs: crate::core::a2a::cost_attribution::CostStore::load(),
104            heatmap: crate::core::heatmap::HeatMap::load(),
105            pricing: ModelPricing::load(),
106            events: crate::core::events::load_events_from_file(500),
107            session: crate::core::session::SessionState::load_latest(),
108        }
109    }
110
111    pub fn summary(&self, model: Option<&str>) -> GainSummary {
112        let quote = self.pricing.quote(model);
113        let tokens_saved = self
114            .stats
115            .total_input_tokens
116            .saturating_sub(self.stats.total_output_tokens);
117        let gain_rate_pct = if self.stats.total_input_tokens > 0 {
118            tokens_saved as f64 / self.stats.total_input_tokens as f64 * 100.0
119        } else {
120            0.0
121        };
122        let avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
123        let tool_spend_usd = self.costs.total_cost().max(0.0);
124        let roi = if tool_spend_usd > 0.0 {
125            Some(avoided_usd / tool_spend_usd)
126        } else {
127            None
128        };
129        let score = GainScore::compute(&self.stats, &self.costs, &self.pricing, model);
130        #[cfg(unix)]
131        let daemon_hint = if crate::daemon::is_daemon_running() {
132            None
133        } else {
134            Some(
135                "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
136                    .to_string(),
137            )
138        };
139        #[cfg(not(unix))]
140        let daemon_hint: Option<String> = None;
141        let injected_overhead_tokens_per_turn =
142            crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
143        // Reconcile to the real bill: the proxy is the only component that sees
144        // every provider turn, so its persisted request count is the honest
145        // multiplier for the per-turn injection tax (GitHub #361).
146        let turns = crate::proxy::metrics::load_persisted().map_or(0, |m| m.requests_total);
147        let (injected_overhead_total_tokens, net_tokens_saved) =
148            net_of_injection(tokens_saved, injected_overhead_tokens_per_turn, turns);
149        GainSummary {
150            model: quote,
151            total_commands: self.stats.total_commands,
152            input_tokens: self.stats.total_input_tokens,
153            output_tokens: self.stats.total_output_tokens,
154            tokens_saved,
155            gain_rate_pct,
156            injected_overhead_tokens_per_turn,
157            turns,
158            injected_overhead_total_tokens,
159            net_tokens_saved,
160            avoided_usd,
161            energy_wh: crate::core::energy::wh_for_tokens(tokens_saved),
162            co2_grams: crate::core::energy::co2_grams_for_tokens(tokens_saved),
163            tool_spend_usd,
164            roi,
165            score,
166            daemon_hint,
167        }
168    }
169
170    pub fn gain_score(&self, model: Option<&str>) -> GainScore {
171        GainScore::compute(&self.stats, &self.costs, &self.pricing, model)
172    }
173
174    pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
175        use std::collections::HashMap;
176
177        let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
178
179        for (cmd_key, st) in &self.stats.commands {
180            let cat = TaskClassifier::classify_command_key(cmd_key);
181            let row = by_cat.entry(cat).or_insert(TaskGainRow {
182                category: cat,
183                commands: 0,
184                tokens_saved: 0,
185                tool_calls: 0,
186                tool_spend_usd: 0.0,
187            });
188            row.commands += st.count;
189            row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
190        }
191
192        for (tool, tc) in &self.costs.tools {
193            let cat = TaskClassifier::classify_tool(tool);
194            let row = by_cat.entry(cat).or_insert(TaskGainRow {
195                category: cat,
196                commands: 0,
197                tokens_saved: 0,
198                tool_calls: 0,
199                tool_spend_usd: 0.0,
200            });
201            row.tool_calls += tc.total_calls;
202            row.tool_spend_usd += tc.cost_usd;
203        }
204
205        let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
206        out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
207        out
208    }
209
210    pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
211        let mut items: Vec<_> = self.heatmap.entries.values().collect();
212        items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
213        items.truncate(limit);
214        items
215            .into_iter()
216            .map(|e| FileGainRow {
217                path: e.path.clone(),
218                access_count: e.access_count,
219                tokens_saved: e.total_tokens_saved,
220                compression_pct: e.avg_compression_ratio * 100.0,
221            })
222            .collect()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::net_of_injection;
229
230    #[test]
231    fn net_of_injection_subtracts_per_turn_tax() {
232        // 1000 saved, 50/turn over 8 turns = 400 tax → net 600.
233        assert_eq!(net_of_injection(1000, 50, 8), (400, 600));
234    }
235
236    #[test]
237    fn net_of_injection_can_go_negative_on_short_runs() {
238        // The honest case the report must not hide: gross < injection tax.
239        assert_eq!(net_of_injection(100, 50, 8), (400, -300));
240    }
241
242    #[test]
243    fn net_of_injection_collapses_to_gross_without_proxy_turns() {
244        // No proxy in the path → no counted turns → net == gross.
245        assert_eq!(net_of_injection(1234, 3000, 0), (0, 1234));
246    }
247}