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