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 #[serde(default)]
41 pub injected_overhead_tokens_per_turn: u64,
42 #[serde(default)]
47 pub turns: u64,
48 #[serde(default)]
51 pub injected_overhead_total_tokens: u64,
52 #[serde(default)]
56 pub net_tokens_saved: i64,
57 #[serde(default)]
60 pub injected_overhead_budget_tokens: u64,
61 #[serde(default)]
63 pub over_budget: bool,
64 pub avoided_usd: f64,
65 pub energy_wh: f64,
67 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
93fn 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 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 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 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 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}