1pub mod bridge_status;
2pub mod evidence;
3#[allow(dead_code)]
4pub mod gain_score;
5#[allow(dead_code)]
6pub mod live_pricing;
7pub mod model_pricing;
8pub mod reach;
9pub mod stream_savings;
10pub mod task_classifier;
11
12use serde::{Deserialize, Serialize};
13
14use crate::core::a2a::cost_attribution::CostStore;
15use crate::core::gain::gain_score::GainScore;
16use crate::core::gain::model_pricing::{ModelPricing, ModelQuote};
17use crate::core::gain::stream_savings::StreamSavings;
18use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
19use crate::core::heatmap::HeatMap;
20use crate::core::stats::StatsStore;
21
22#[derive(Clone)]
23pub struct GainEngine {
24 pub stats: StatsStore,
25 pub costs: CostStore,
26 pub heatmap: HeatMap,
27 pub pricing: ModelPricing,
28 pub events: Vec<crate::core::events::LeanCtxEvent>,
29 pub session: Option<crate::core::session::SessionState>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct GainSummary {
34 pub model: ModelQuote,
35 pub total_commands: u64,
36 pub input_tokens: u64,
37 pub output_tokens: u64,
38 pub tokens_saved: u64,
39 #[serde(default)]
41 pub effective_input_tokens: u64,
42 #[serde(default)]
43 pub effective_output_tokens: u64,
44 #[serde(default)]
45 pub effective_tokens_saved: u64,
46 #[serde(default)]
48 pub effective_compression_pct: f64,
49 #[serde(default)]
51 pub total_reduction_pct: f64,
52 pub gain_rate_pct: f64,
53 #[serde(default)]
60 pub injected_overhead_tokens_per_turn: u64,
61 #[serde(default)]
65 pub turns: u64,
66 #[serde(default)]
69 pub injected_overhead_total_tokens: u64,
70 #[serde(default)]
74 pub net_tokens_saved: i64,
75 #[serde(default)]
77 pub economic_evidence: evidence::EconomicEvidence,
78 #[serde(default)]
80 pub provider_path_observed: bool,
81 #[serde(default)]
84 pub net_bill_impact_tokens: Option<i64>,
85 #[serde(default)]
87 pub net_bill_impact_usd: Option<f64>,
88 #[serde(default)]
91 pub injected_overhead_budget_tokens: u64,
92 #[serde(default)]
94 pub over_budget: bool,
95 pub avoided_usd: f64,
96 #[serde(default)]
98 pub stream_savings: StreamSavings,
99 pub energy_wh: f64,
101 pub co2_grams: f64,
103 pub tool_spend_usd: f64,
104 pub roi: Option<f64>,
105 pub score: GainScore,
106 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub daemon_hint: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TaskGainRow {
112 pub category: TaskCategory,
113 pub commands: u64,
114 pub tokens_saved: u64,
115 pub tool_calls: u64,
116 pub tool_spend_usd: f64,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct FileGainRow {
121 pub path: String,
122 pub access_count: u32,
123 pub tokens_saved: u64,
124 pub compression_pct: f32,
125}
126
127fn current_project_navigability() -> Option<u32> {
132 let root = crate::core::config::Config::find_project_root()?;
133 crate::core::code_health::persist::load(&root).map(|h| h.score.score)
134}
135
136impl GainEngine {
137 pub fn load() -> Self {
138 Self {
139 stats: crate::core::stats::load_for_display(),
142 costs: crate::core::a2a::cost_attribution::CostStore::load(),
143 heatmap: crate::core::heatmap::HeatMap::load(),
144 pricing: ModelPricing::load(),
145 events: crate::core::events::load_events_from_file(500),
146 session: crate::core::session::SessionState::load_latest(),
147 }
148 }
149
150 pub fn summary(&self, model: Option<&str>) -> GainSummary {
151 let quote = self.pricing.quote(model);
152 let total_tokens_saved = self
153 .stats
154 .total_input_tokens
155 .saturating_sub(self.stats.total_output_tokens);
156 let effective = self.stats.compression_totals();
157 let effective_tokens_saved = effective.saved_tokens();
158 let effective_compression_pct = effective.compression_pct();
159 let total_reduction_pct = self.stats.total_reduction_pct();
160 let turns = crate::core::context_overhead::observed_turns();
161 let (first_inject, reread) = self.stats.stream_savings(turns);
162 let injected_overhead_tokens_per_turn =
163 crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
164 let bounce_tokens = crate::core::bounce_tracker::global()
165 .lock()
166 .unwrap_or_else(std::sync::PoisonError::into_inner)
167 .total_wasted_tokens() as u64;
168 let stream_savings = StreamSavings::calculate(
169 first_inject,
170 reread,
171 injected_overhead_tokens_per_turn,
172 crate::core::context_overhead::native_baseline_tokens_per_turn(),
173 turns,
174 bounce_tokens,
175 quote.cost,
176 );
177 let avoided_usd = stream_savings.net_usd_saved;
178 let tool_spend_usd = self.costs.total_cost().max(0.0);
179 let score = GainScore::compute(
180 &self.stats,
181 &self.costs,
182 &self.pricing,
183 model,
184 current_project_navigability(),
185 );
186 let daemon_hint = if crate::daemon::is_daemon_running() {
189 None
190 } else {
191 Some(
192 "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
193 .to_string(),
194 )
195 };
196 let (injected_overhead_total_tokens, net_tokens_saved) =
201 crate::core::context_overhead::net_of_injection(
202 effective_tokens_saved,
203 injected_overhead_tokens_per_turn,
204 turns,
205 );
206 let economics = evidence::economic_view(
209 self.stats.total_commands,
210 turns,
211 net_tokens_saved,
212 avoided_usd,
213 tool_spend_usd,
214 );
215 let roi = economics.roi;
216 let injected_overhead_budget_tokens =
220 crate::core::config::Config::load().context_budget_tokens_effective() as u64;
221 let over_budget = injected_overhead_budget_tokens > 0
222 && injected_overhead_tokens_per_turn > injected_overhead_budget_tokens;
223 GainSummary {
224 model: quote,
225 total_commands: self.stats.total_commands,
226 input_tokens: self.stats.total_input_tokens,
227 output_tokens: self.stats.total_output_tokens,
228 tokens_saved: total_tokens_saved,
229 effective_input_tokens: effective.input_tokens,
230 effective_output_tokens: effective.output_tokens,
231 effective_tokens_saved,
232 effective_compression_pct,
233 total_reduction_pct,
234 gain_rate_pct: effective_compression_pct,
235 injected_overhead_tokens_per_turn,
236 turns,
237 injected_overhead_total_tokens,
238 net_tokens_saved,
239 economic_evidence: economics.evidence,
240 provider_path_observed: economics.evidence.bill_impact_observable(),
241 net_bill_impact_tokens: economics.net_bill_impact_tokens,
242 net_bill_impact_usd: economics.net_bill_impact_usd,
243 injected_overhead_budget_tokens,
244 over_budget,
245 avoided_usd,
246 stream_savings,
247 energy_wh: crate::core::energy::wh_for_tokens(effective_tokens_saved),
248 co2_grams: crate::core::energy::co2_grams_for_tokens(effective_tokens_saved),
249 tool_spend_usd,
250 roi,
251 score,
252 daemon_hint,
253 }
254 }
255
256 pub fn gain_score(&self, model: Option<&str>) -> GainScore {
257 GainScore::compute(
258 &self.stats,
259 &self.costs,
260 &self.pricing,
261 model,
262 current_project_navigability(),
263 )
264 }
265
266 pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
267 use std::collections::HashMap;
268
269 let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
270
271 for (cmd_key, st) in &self.stats.commands {
272 let cat = TaskClassifier::classify_command_key(cmd_key);
273 let row = by_cat.entry(cat).or_insert(TaskGainRow {
274 category: cat,
275 commands: 0,
276 tokens_saved: 0,
277 tool_calls: 0,
278 tool_spend_usd: 0.0,
279 });
280 row.commands += st.count;
281 row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
282 }
283
284 for (tool, tc) in &self.costs.tools {
285 let cat = TaskClassifier::classify_tool(tool);
286 let row = by_cat.entry(cat).or_insert(TaskGainRow {
287 category: cat,
288 commands: 0,
289 tokens_saved: 0,
290 tool_calls: 0,
291 tool_spend_usd: 0.0,
292 });
293 row.tool_calls += tc.total_calls;
294 row.tool_spend_usd += tc.cost_usd;
295 }
296
297 let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
298 out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
299 out
300 }
301
302 pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
303 let mut items: Vec<_> = self.heatmap.entries.values().collect();
304 items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
305 items.truncate(limit);
306 items
307 .into_iter()
308 .map(|e| FileGainRow {
309 path: e.path.clone(),
310 access_count: e.access_count,
311 tokens_saved: e.total_tokens_saved,
312 compression_pct: e.avg_compression_ratio * 100.0,
313 })
314 .collect()
315 }
316}