Skip to main content

lean_ctx/core/gain/
mod.rs

1pub mod bridge_status;
2pub mod gain_score;
3pub mod live_pricing;
4pub mod model_pricing;
5pub mod stream_savings;
6pub mod task_classifier;
7
8use serde::{Deserialize, Serialize};
9
10use crate::core::a2a::cost_attribution::CostStore;
11use crate::core::gain::gain_score::GainScore;
12use crate::core::gain::model_pricing::{ModelPricing, ModelQuote};
13use crate::core::gain::stream_savings::StreamSavings;
14use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
15use crate::core::heatmap::HeatMap;
16use crate::core::stats::StatsStore;
17
18#[derive(Clone)]
19pub struct GainEngine {
20    pub stats: StatsStore,
21    pub costs: CostStore,
22    pub heatmap: HeatMap,
23    pub pricing: ModelPricing,
24    pub events: Vec<crate::core::events::LeanCtxEvent>,
25    pub session: Option<crate::core::session::SessionState>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct GainSummary {
30    pub model: ModelQuote,
31    pub total_commands: u64,
32    pub input_tokens: u64,
33    pub output_tokens: u64,
34    pub tokens_saved: u64,
35    /// Tokens removed from traffic whose output lean-ctx controls.
36    #[serde(default)]
37    pub effective_input_tokens: u64,
38    #[serde(default)]
39    pub effective_output_tokens: u64,
40    #[serde(default)]
41    pub effective_tokens_saved: u64,
42    /// Compression percentage over compressible traffic only.
43    #[serde(default)]
44    pub effective_compression_pct: f64,
45    /// Reduction percentage over all recorded traffic, including passthrough.
46    #[serde(default)]
47    pub total_reduction_pct: f64,
48    pub gain_rate_pct: f64,
49    /// Fixed per-turn context lean-ctx injects (tool schemas + server
50    /// instructions + rules block). On a provider WITHOUT prompt caching this
51    /// rides — and is re-billed on — every turn, so the net bill impact is
52    /// `tokens_saved − injected_overhead_tokens_per_turn × turns`. Note that
53    /// `tokens_saved` / `gain_rate_pct` are measured against lean-ctx-touched
54    /// traffic (their denominator), not the full provider bill (GitHub #361).
55    #[serde(default)]
56    pub injected_overhead_tokens_per_turn: u64,
57    /// Provider turns (requests) the proxy actually saw carry the injected
58    /// prefix. `0` when the proxy is not in the request path, in which case the
59    /// net figure below collapses to the gross `tokens_saved` (we cannot count
60    /// turns we never observed, and we refuse to guess).
61    #[serde(default)]
62    pub turns: u64,
63    /// `injected_overhead_tokens_per_turn × turns` — the total fixed context tax
64    /// re-billed across the run on a provider without prompt caching.
65    #[serde(default)]
66    pub injected_overhead_total_tokens: u64,
67    /// The honest bill impact: `tokens_saved − injected_overhead_total_tokens`.
68    /// Signed, because on a non-caching rail a short run can legitimately go
69    /// net-negative until savings outgrow the per-turn injection.
70    #[serde(default)]
71    pub net_tokens_saved: i64,
72    /// Configured fixed-context budget (`[context] budget_tokens`); 0 disables
73    /// the check (#964). Surfaced so `gain` can flag a bloated injected prefix.
74    #[serde(default)]
75    pub injected_overhead_budget_tokens: u64,
76    /// Whether `injected_overhead_tokens_per_turn` exceeds a non-zero budget (#964).
77    #[serde(default)]
78    pub over_budget: bool,
79    pub avoided_usd: f64,
80    /// Bill-weighted savings split by provider token stream (#1191).
81    #[serde(default)]
82    pub stream_savings: StreamSavings,
83    /// Estimated grid energy avoided (Wh) by keeping `tokens_saved` out of context.
84    pub energy_wh: f64,
85    /// Estimated CO₂-equivalent avoided (grams), derived from `energy_wh`.
86    pub co2_grams: f64,
87    pub tool_spend_usd: f64,
88    pub roi: Option<f64>,
89    pub score: GainScore,
90    #[serde(skip_serializing_if = "Option::is_none", default)]
91    pub daemon_hint: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct TaskGainRow {
96    pub category: TaskCategory,
97    pub commands: u64,
98    pub tokens_saved: u64,
99    pub tool_calls: u64,
100    pub tool_spend_usd: f64,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FileGainRow {
105    pub path: String,
106    pub access_count: u32,
107    pub tokens_saved: u64,
108    pub compression_pct: f32,
109}
110
111/// Navigability (0–100) of the project containing the current working dir, from
112/// the persisted Code Health report. `None` when not in a project or the engine
113/// has not computed health yet — the gain score then falls back to its legacy
114/// four-component weighting so users are never penalised (#1086).
115fn current_project_navigability() -> Option<u32> {
116    let root = crate::core::config::Config::find_project_root()?;
117    crate::core::code_health::persist::load(&root).map(|h| h.score.score)
118}
119
120impl GainEngine {
121    pub fn load() -> Self {
122        Self {
123            // Aggregate across split data dirs so the gain score, cost view and
124            // net-of-injection line agree with the hero headline (#500).
125            stats: crate::core::stats::load_for_display(),
126            costs: crate::core::a2a::cost_attribution::CostStore::load(),
127            heatmap: crate::core::heatmap::HeatMap::load(),
128            pricing: ModelPricing::load(),
129            events: crate::core::events::load_events_from_file(500),
130            session: crate::core::session::SessionState::load_latest(),
131        }
132    }
133
134    pub fn summary(&self, model: Option<&str>) -> GainSummary {
135        let quote = self.pricing.quote(model);
136        let total_tokens_saved = self
137            .stats
138            .total_input_tokens
139            .saturating_sub(self.stats.total_output_tokens);
140        let effective = self.stats.compression_totals();
141        let effective_tokens_saved = effective.saved_tokens();
142        let effective_compression_pct = effective.compression_pct();
143        let total_reduction_pct = self.stats.total_reduction_pct();
144        let turns = crate::core::context_overhead::observed_turns();
145        let (first_inject, reread) = self.stats.stream_savings(turns);
146        let injected_overhead_tokens_per_turn =
147            crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
148        let bounce_tokens = crate::core::bounce_tracker::global()
149            .lock()
150            .unwrap_or_else(std::sync::PoisonError::into_inner)
151            .total_wasted_tokens() as u64;
152        let stream_savings = StreamSavings::calculate(
153            first_inject,
154            reread,
155            injected_overhead_tokens_per_turn,
156            turns,
157            bounce_tokens,
158            quote.cost,
159        );
160        let avoided_usd = stream_savings.net_usd_saved;
161        let tool_spend_usd = self.costs.total_cost().max(0.0);
162        let roi = if tool_spend_usd > 0.0 {
163            Some(avoided_usd / tool_spend_usd)
164        } else {
165            None
166        };
167        let score = GainScore::compute(
168            &self.stats,
169            &self.costs,
170            &self.pricing,
171            model,
172            current_project_navigability(),
173        );
174        // is_daemon_running() is cross-platform; the old #[cfg(not(unix))] = None
175        // branch suppressed daemon state on Windows. See #576.
176        let daemon_hint = if crate::daemon::is_daemon_running() {
177            None
178        } else {
179            Some(
180                "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
181                    .to_string(),
182            )
183        };
184        // Reconcile to the real bill: the proxy is the only component that sees
185        // every provider turn, so its persisted request count is the honest
186        // multiplier for the per-turn injection tax (GitHub #361). The math is
187        // shared with the verified savings ledger/ROI (#685).
188        let (injected_overhead_total_tokens, net_tokens_saved) =
189            crate::core::context_overhead::net_of_injection(
190                effective_tokens_saved,
191                injected_overhead_tokens_per_turn,
192                turns,
193            );
194        // Budget awareness (#964): flag when the fixed per-turn prefix outgrows
195        // the configured `[context] budget_tokens` (shared knob with `doctor
196        // overhead`). 0 disables the check.
197        let injected_overhead_budget_tokens =
198            crate::core::config::Config::load().context_budget_tokens_effective() as u64;
199        let over_budget = injected_overhead_budget_tokens > 0
200            && injected_overhead_tokens_per_turn > injected_overhead_budget_tokens;
201        GainSummary {
202            model: quote,
203            total_commands: self.stats.total_commands,
204            input_tokens: self.stats.total_input_tokens,
205            output_tokens: self.stats.total_output_tokens,
206            tokens_saved: total_tokens_saved,
207            effective_input_tokens: effective.input_tokens,
208            effective_output_tokens: effective.output_tokens,
209            effective_tokens_saved,
210            effective_compression_pct,
211            total_reduction_pct,
212            gain_rate_pct: effective_compression_pct,
213            injected_overhead_tokens_per_turn,
214            turns,
215            injected_overhead_total_tokens,
216            net_tokens_saved,
217            injected_overhead_budget_tokens,
218            over_budget,
219            avoided_usd,
220            stream_savings,
221            energy_wh: crate::core::energy::wh_for_tokens(effective_tokens_saved),
222            co2_grams: crate::core::energy::co2_grams_for_tokens(effective_tokens_saved),
223            tool_spend_usd,
224            roi,
225            score,
226            daemon_hint,
227        }
228    }
229
230    pub fn gain_score(&self, model: Option<&str>) -> GainScore {
231        GainScore::compute(
232            &self.stats,
233            &self.costs,
234            &self.pricing,
235            model,
236            current_project_navigability(),
237        )
238    }
239
240    pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
241        use std::collections::HashMap;
242
243        let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
244
245        for (cmd_key, st) in &self.stats.commands {
246            let cat = TaskClassifier::classify_command_key(cmd_key);
247            let row = by_cat.entry(cat).or_insert(TaskGainRow {
248                category: cat,
249                commands: 0,
250                tokens_saved: 0,
251                tool_calls: 0,
252                tool_spend_usd: 0.0,
253            });
254            row.commands += st.count;
255            row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
256        }
257
258        for (tool, tc) in &self.costs.tools {
259            let cat = TaskClassifier::classify_tool(tool);
260            let row = by_cat.entry(cat).or_insert(TaskGainRow {
261                category: cat,
262                commands: 0,
263                tokens_saved: 0,
264                tool_calls: 0,
265                tool_spend_usd: 0.0,
266            });
267            row.tool_calls += tc.total_calls;
268            row.tool_spend_usd += tc.cost_usd;
269        }
270
271        let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
272        out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
273        out
274    }
275
276    pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
277        let mut items: Vec<_> = self.heatmap.entries.values().collect();
278        items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
279        items.truncate(limit);
280        items
281            .into_iter()
282            .map(|e| FileGainRow {
283                path: e.path.clone(),
284                access_count: e.access_count,
285                tokens_saved: e.total_tokens_saved,
286                compression_pct: e.avg_compression_ratio * 100.0,
287            })
288            .collect()
289    }
290}