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    /// Configured fixed-context budget (`[context] budget_tokens`); 0 disables
57    /// the check (#964). Surfaced so `gain` can flag a bloated injected prefix.
58    #[serde(default)]
59    pub injected_overhead_budget_tokens: u64,
60    /// Whether `injected_overhead_tokens_per_turn` exceeds a non-zero budget (#964).
61    #[serde(default)]
62    pub over_budget: bool,
63    pub avoided_usd: f64,
64    /// Estimated grid energy avoided (Wh) by keeping `tokens_saved` out of context.
65    pub energy_wh: f64,
66    /// Estimated CO₂-equivalent avoided (grams), derived from `energy_wh`.
67    pub co2_grams: f64,
68    pub tool_spend_usd: f64,
69    pub roi: Option<f64>,
70    pub score: GainScore,
71    #[serde(skip_serializing_if = "Option::is_none", default)]
72    pub daemon_hint: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct TaskGainRow {
77    pub category: TaskCategory,
78    pub commands: u64,
79    pub tokens_saved: u64,
80    pub tool_calls: u64,
81    pub tool_spend_usd: f64,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct FileGainRow {
86    pub path: String,
87    pub access_count: u32,
88    pub tokens_saved: u64,
89    pub compression_pct: f32,
90}
91
92impl GainEngine {
93    pub fn load() -> Self {
94        Self {
95            // Aggregate across split data dirs so the gain score, cost view and
96            // net-of-injection line agree with the hero headline (#500).
97            stats: crate::core::stats::load_for_display(),
98            costs: crate::core::a2a::cost_attribution::CostStore::load(),
99            heatmap: crate::core::heatmap::HeatMap::load(),
100            pricing: ModelPricing::load(),
101            events: crate::core::events::load_events_from_file(500),
102            session: crate::core::session::SessionState::load_latest(),
103        }
104    }
105
106    pub fn summary(&self, model: Option<&str>) -> GainSummary {
107        let quote = self.pricing.quote(model);
108        let tokens_saved = self
109            .stats
110            .total_input_tokens
111            .saturating_sub(self.stats.total_output_tokens);
112        let gain_rate_pct = if self.stats.total_input_tokens > 0 {
113            tokens_saved as f64 / self.stats.total_input_tokens as f64 * 100.0
114        } else {
115            0.0
116        };
117        let avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
118        let tool_spend_usd = self.costs.total_cost().max(0.0);
119        let roi = if tool_spend_usd > 0.0 {
120            Some(avoided_usd / tool_spend_usd)
121        } else {
122            None
123        };
124        let score = GainScore::compute(&self.stats, &self.costs, &self.pricing, model);
125        // is_daemon_running() is cross-platform; the old #[cfg(not(unix))] = None
126        // branch suppressed daemon state on Windows. See #576.
127        let daemon_hint = if crate::daemon::is_daemon_running() {
128            None
129        } else {
130            Some(
131                "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
132                    .to_string(),
133            )
134        };
135        let injected_overhead_tokens_per_turn =
136            crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
137        // Reconcile to the real bill: the proxy is the only component that sees
138        // every provider turn, so its persisted request count is the honest
139        // multiplier for the per-turn injection tax (GitHub #361). The math is
140        // shared with the verified savings ledger/ROI (#685).
141        let turns = crate::core::context_overhead::observed_turns();
142        let (injected_overhead_total_tokens, net_tokens_saved) =
143            crate::core::context_overhead::net_of_injection(
144                tokens_saved,
145                injected_overhead_tokens_per_turn,
146                turns,
147            );
148        // Budget awareness (#964): flag when the fixed per-turn prefix outgrows
149        // the configured `[context] budget_tokens` (shared knob with `doctor
150        // overhead`). 0 disables the check.
151        let injected_overhead_budget_tokens =
152            crate::core::config::Config::load().context_budget_tokens_effective() as u64;
153        let over_budget = injected_overhead_budget_tokens > 0
154            && injected_overhead_tokens_per_turn > injected_overhead_budget_tokens;
155        GainSummary {
156            model: quote,
157            total_commands: self.stats.total_commands,
158            input_tokens: self.stats.total_input_tokens,
159            output_tokens: self.stats.total_output_tokens,
160            tokens_saved,
161            gain_rate_pct,
162            injected_overhead_tokens_per_turn,
163            turns,
164            injected_overhead_total_tokens,
165            net_tokens_saved,
166            injected_overhead_budget_tokens,
167            over_budget,
168            avoided_usd,
169            energy_wh: crate::core::energy::wh_for_tokens(tokens_saved),
170            co2_grams: crate::core::energy::co2_grams_for_tokens(tokens_saved),
171            tool_spend_usd,
172            roi,
173            score,
174            daemon_hint,
175        }
176    }
177
178    pub fn gain_score(&self, model: Option<&str>) -> GainScore {
179        GainScore::compute(&self.stats, &self.costs, &self.pricing, model)
180    }
181
182    pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
183        use std::collections::HashMap;
184
185        let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
186
187        for (cmd_key, st) in &self.stats.commands {
188            let cat = TaskClassifier::classify_command_key(cmd_key);
189            let row = by_cat.entry(cat).or_insert(TaskGainRow {
190                category: cat,
191                commands: 0,
192                tokens_saved: 0,
193                tool_calls: 0,
194                tool_spend_usd: 0.0,
195            });
196            row.commands += st.count;
197            row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
198        }
199
200        for (tool, tc) in &self.costs.tools {
201            let cat = TaskClassifier::classify_tool(tool);
202            let row = by_cat.entry(cat).or_insert(TaskGainRow {
203                category: cat,
204                commands: 0,
205                tokens_saved: 0,
206                tool_calls: 0,
207                tool_spend_usd: 0.0,
208            });
209            row.tool_calls += tc.total_calls;
210            row.tool_spend_usd += tc.cost_usd;
211        }
212
213        let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
214        out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
215        out
216    }
217
218    pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
219        let mut items: Vec<_> = self.heatmap.entries.values().collect();
220        items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
221        items.truncate(limit);
222        items
223            .into_iter()
224            .map(|e| FileGainRow {
225                path: e.path.clone(),
226                access_count: e.access_count,
227                tokens_saved: e.total_tokens_saved,
228                compression_pct: e.avg_compression_ratio * 100.0,
229            })
230            .collect()
231    }
232}