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 #[serde(default)]
40 pub injected_overhead_tokens_per_turn: u64,
41 #[serde(default)]
46 pub turns: u64,
47 #[serde(default)]
50 pub injected_overhead_total_tokens: u64,
51 #[serde(default)]
55 pub net_tokens_saved: i64,
56 #[serde(default)]
59 pub injected_overhead_budget_tokens: u64,
60 #[serde(default)]
62 pub over_budget: bool,
63 pub avoided_usd: f64,
64 pub energy_wh: f64,
66 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
92fn current_project_navigability() -> Option<u32> {
97 let root = crate::core::config::Config::find_project_root()?;
98 crate::core::code_health::persist::load(&root).map(|h| h.score.score)
99}
100
101impl GainEngine {
102 pub fn load() -> Self {
103 Self {
104 stats: crate::core::stats::load_for_display(),
107 costs: crate::core::a2a::cost_attribution::CostStore::load(),
108 heatmap: crate::core::heatmap::HeatMap::load(),
109 pricing: ModelPricing::load(),
110 events: crate::core::events::load_events_from_file(500),
111 session: crate::core::session::SessionState::load_latest(),
112 }
113 }
114
115 pub fn summary(&self, model: Option<&str>) -> GainSummary {
116 let quote = self.pricing.quote(model);
117 let tokens_saved = self
118 .stats
119 .total_input_tokens
120 .saturating_sub(self.stats.total_output_tokens);
121 let gain_rate_pct = if self.stats.total_input_tokens > 0 {
122 tokens_saved as f64 / self.stats.total_input_tokens as f64 * 100.0
123 } else {
124 0.0
125 };
126 let avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
127 let tool_spend_usd = self.costs.total_cost().max(0.0);
128 let roi = if tool_spend_usd > 0.0 {
129 Some(avoided_usd / tool_spend_usd)
130 } else {
131 None
132 };
133 let score = GainScore::compute(
134 &self.stats,
135 &self.costs,
136 &self.pricing,
137 model,
138 current_project_navigability(),
139 );
140 let daemon_hint = if crate::daemon::is_daemon_running() {
143 None
144 } else {
145 Some(
146 "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
147 .to_string(),
148 )
149 };
150 let injected_overhead_tokens_per_turn =
151 crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
152 let turns = crate::core::context_overhead::observed_turns();
157 let (injected_overhead_total_tokens, net_tokens_saved) =
158 crate::core::context_overhead::net_of_injection(
159 tokens_saved,
160 injected_overhead_tokens_per_turn,
161 turns,
162 );
163 let injected_overhead_budget_tokens =
167 crate::core::config::Config::load().context_budget_tokens_effective() as u64;
168 let over_budget = injected_overhead_budget_tokens > 0
169 && injected_overhead_tokens_per_turn > injected_overhead_budget_tokens;
170 GainSummary {
171 model: quote,
172 total_commands: self.stats.total_commands,
173 input_tokens: self.stats.total_input_tokens,
174 output_tokens: self.stats.total_output_tokens,
175 tokens_saved,
176 gain_rate_pct,
177 injected_overhead_tokens_per_turn,
178 turns,
179 injected_overhead_total_tokens,
180 net_tokens_saved,
181 injected_overhead_budget_tokens,
182 over_budget,
183 avoided_usd,
184 energy_wh: crate::core::energy::wh_for_tokens(tokens_saved),
185 co2_grams: crate::core::energy::co2_grams_for_tokens(tokens_saved),
186 tool_spend_usd,
187 roi,
188 score,
189 daemon_hint,
190 }
191 }
192
193 pub fn gain_score(&self, model: Option<&str>) -> GainScore {
194 GainScore::compute(
195 &self.stats,
196 &self.costs,
197 &self.pricing,
198 model,
199 current_project_navigability(),
200 )
201 }
202
203 pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
204 use std::collections::HashMap;
205
206 let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
207
208 for (cmd_key, st) in &self.stats.commands {
209 let cat = TaskClassifier::classify_command_key(cmd_key);
210 let row = by_cat.entry(cat).or_insert(TaskGainRow {
211 category: cat,
212 commands: 0,
213 tokens_saved: 0,
214 tool_calls: 0,
215 tool_spend_usd: 0.0,
216 });
217 row.commands += st.count;
218 row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
219 }
220
221 for (tool, tc) in &self.costs.tools {
222 let cat = TaskClassifier::classify_tool(tool);
223 let row = by_cat.entry(cat).or_insert(TaskGainRow {
224 category: cat,
225 commands: 0,
226 tokens_saved: 0,
227 tool_calls: 0,
228 tool_spend_usd: 0.0,
229 });
230 row.tool_calls += tc.total_calls;
231 row.tool_spend_usd += tc.cost_usd;
232 }
233
234 let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
235 out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
236 out
237 }
238
239 pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
240 let mut items: Vec<_> = self.heatmap.entries.values().collect();
241 items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
242 items.truncate(limit);
243 items
244 .into_iter()
245 .map(|e| FileGainRow {
246 path: e.path.clone(),
247 access_count: e.access_count,
248 tokens_saved: e.total_tokens_saved,
249 compression_pct: e.avg_compression_ratio * 100.0,
250 })
251 .collect()
252 }
253}