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 pub avoided_usd: f64,
57 pub energy_wh: f64,
59 pub co2_grams: f64,
61 pub tool_spend_usd: f64,
62 pub roi: Option<f64>,
63 pub score: GainScore,
64 #[serde(skip_serializing_if = "Option::is_none", default)]
65 pub daemon_hint: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TaskGainRow {
70 pub category: TaskCategory,
71 pub commands: u64,
72 pub tokens_saved: u64,
73 pub tool_calls: u64,
74 pub tool_spend_usd: f64,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct FileGainRow {
79 pub path: String,
80 pub access_count: u32,
81 pub tokens_saved: u64,
82 pub compression_pct: f32,
83}
84
85pub(crate) fn net_of_injection(
90 tokens_saved: u64,
91 overhead_per_turn: u64,
92 turns: u64,
93) -> (u64, i64) {
94 let total = overhead_per_turn.saturating_mul(turns);
95 let net = tokens_saved as i64 - total as i64;
96 (total, net)
97}
98
99impl GainEngine {
100 pub fn load() -> Self {
101 Self {
102 stats: crate::core::stats::load(),
103 costs: crate::core::a2a::cost_attribution::CostStore::load(),
104 heatmap: crate::core::heatmap::HeatMap::load(),
105 pricing: ModelPricing::load(),
106 events: crate::core::events::load_events_from_file(500),
107 session: crate::core::session::SessionState::load_latest(),
108 }
109 }
110
111 pub fn summary(&self, model: Option<&str>) -> GainSummary {
112 let quote = self.pricing.quote(model);
113 let tokens_saved = self
114 .stats
115 .total_input_tokens
116 .saturating_sub(self.stats.total_output_tokens);
117 let gain_rate_pct = if self.stats.total_input_tokens > 0 {
118 tokens_saved as f64 / self.stats.total_input_tokens as f64 * 100.0
119 } else {
120 0.0
121 };
122 let avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
123 let tool_spend_usd = self.costs.total_cost().max(0.0);
124 let roi = if tool_spend_usd > 0.0 {
125 Some(avoided_usd / tool_spend_usd)
126 } else {
127 None
128 };
129 let score = GainScore::compute(&self.stats, &self.costs, &self.pricing, model);
130 #[cfg(unix)]
131 let daemon_hint = if crate::daemon::is_daemon_running() {
132 None
133 } else {
134 Some(
135 "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
136 .to_string(),
137 )
138 };
139 #[cfg(not(unix))]
140 let daemon_hint: Option<String> = None;
141 let injected_overhead_tokens_per_turn =
142 crate::core::context_overhead::ContextOverhead::cached().total_tokens() as u64;
143 let turns = crate::proxy::metrics::load_persisted().map_or(0, |m| m.requests_total);
147 let (injected_overhead_total_tokens, net_tokens_saved) =
148 net_of_injection(tokens_saved, injected_overhead_tokens_per_turn, turns);
149 GainSummary {
150 model: quote,
151 total_commands: self.stats.total_commands,
152 input_tokens: self.stats.total_input_tokens,
153 output_tokens: self.stats.total_output_tokens,
154 tokens_saved,
155 gain_rate_pct,
156 injected_overhead_tokens_per_turn,
157 turns,
158 injected_overhead_total_tokens,
159 net_tokens_saved,
160 avoided_usd,
161 energy_wh: crate::core::energy::wh_for_tokens(tokens_saved),
162 co2_grams: crate::core::energy::co2_grams_for_tokens(tokens_saved),
163 tool_spend_usd,
164 roi,
165 score,
166 daemon_hint,
167 }
168 }
169
170 pub fn gain_score(&self, model: Option<&str>) -> GainScore {
171 GainScore::compute(&self.stats, &self.costs, &self.pricing, model)
172 }
173
174 pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
175 use std::collections::HashMap;
176
177 let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
178
179 for (cmd_key, st) in &self.stats.commands {
180 let cat = TaskClassifier::classify_command_key(cmd_key);
181 let row = by_cat.entry(cat).or_insert(TaskGainRow {
182 category: cat,
183 commands: 0,
184 tokens_saved: 0,
185 tool_calls: 0,
186 tool_spend_usd: 0.0,
187 });
188 row.commands += st.count;
189 row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
190 }
191
192 for (tool, tc) in &self.costs.tools {
193 let cat = TaskClassifier::classify_tool(tool);
194 let row = by_cat.entry(cat).or_insert(TaskGainRow {
195 category: cat,
196 commands: 0,
197 tokens_saved: 0,
198 tool_calls: 0,
199 tool_spend_usd: 0.0,
200 });
201 row.tool_calls += tc.total_calls;
202 row.tool_spend_usd += tc.cost_usd;
203 }
204
205 let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
206 out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
207 out
208 }
209
210 pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
211 let mut items: Vec<_> = self.heatmap.entries.values().collect();
212 items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
213 items.truncate(limit);
214 items
215 .into_iter()
216 .map(|e| FileGainRow {
217 path: e.path.clone(),
218 access_count: e.access_count,
219 tokens_saved: e.total_tokens_saved,
220 compression_pct: e.avg_compression_ratio * 100.0,
221 })
222 .collect()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::net_of_injection;
229
230 #[test]
231 fn net_of_injection_subtracts_per_turn_tax() {
232 assert_eq!(net_of_injection(1000, 50, 8), (400, 600));
234 }
235
236 #[test]
237 fn net_of_injection_can_go_negative_on_short_runs() {
238 assert_eq!(net_of_injection(100, 50, 8), (400, -300));
240 }
241
242 #[test]
243 fn net_of_injection_collapses_to_gross_without_proxy_turns() {
244 assert_eq!(net_of_injection(1234, 3000, 0), (0, 1234));
246 }
247}