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 #[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 #[serde(default)]
44 pub effective_compression_pct: f64,
45 #[serde(default)]
47 pub total_reduction_pct: f64,
48 pub gain_rate_pct: f64,
49 #[serde(default)]
56 pub injected_overhead_tokens_per_turn: u64,
57 #[serde(default)]
62 pub turns: u64,
63 #[serde(default)]
66 pub injected_overhead_total_tokens: u64,
67 #[serde(default)]
71 pub net_tokens_saved: i64,
72 #[serde(default)]
75 pub injected_overhead_budget_tokens: u64,
76 #[serde(default)]
78 pub over_budget: bool,
79 pub avoided_usd: f64,
80 #[serde(default)]
82 pub stream_savings: StreamSavings,
83 pub energy_wh: f64,
85 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
111fn 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 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 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 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 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}