1pub mod gain_score;
2pub mod model_pricing;
3pub mod task_classifier;
4
5use serde::{Deserialize, Serialize};
6
7use crate::core::a2a::cost_attribution::CostStore;
8use crate::core::gain::gain_score::GainScore;
9use crate::core::gain::model_pricing::{ModelPricing, ModelQuote};
10use crate::core::gain::task_classifier::{TaskCategory, TaskClassifier};
11use crate::core::heatmap::HeatMap;
12use crate::core::stats::StatsStore;
13
14#[derive(Clone)]
15pub struct GainEngine {
16 pub stats: StatsStore,
17 pub costs: CostStore,
18 pub heatmap: HeatMap,
19 pub pricing: ModelPricing,
20 pub events: Vec<crate::core::events::LeanCtxEvent>,
21 pub session: Option<crate::core::session::SessionState>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct GainSummary {
26 pub model: ModelQuote,
27 pub total_commands: u64,
28 pub input_tokens: u64,
29 pub output_tokens: u64,
30 pub tokens_saved: u64,
31 pub gain_rate_pct: f64,
32 pub avoided_usd: f64,
33 pub tool_spend_usd: f64,
34 pub roi: Option<f64>,
35 pub score: GainScore,
36 #[serde(skip_serializing_if = "Option::is_none", default)]
37 pub daemon_hint: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct TaskGainRow {
42 pub category: TaskCategory,
43 pub commands: u64,
44 pub tokens_saved: u64,
45 pub tool_calls: u64,
46 pub tool_spend_usd: f64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct FileGainRow {
51 pub path: String,
52 pub access_count: u32,
53 pub tokens_saved: u64,
54 pub compression_pct: f32,
55}
56
57impl GainEngine {
58 pub fn load() -> Self {
59 Self {
60 stats: crate::core::stats::load(),
61 costs: crate::core::a2a::cost_attribution::CostStore::load(),
62 heatmap: crate::core::heatmap::HeatMap::load(),
63 pricing: ModelPricing::load(),
64 events: crate::core::events::load_events_from_file(500),
65 session: crate::core::session::SessionState::load_latest(),
66 }
67 }
68
69 pub fn summary(&self, model: Option<&str>) -> GainSummary {
70 let quote = self.pricing.quote(model);
71 let tokens_saved = self
72 .stats
73 .total_input_tokens
74 .saturating_sub(self.stats.total_output_tokens);
75 let gain_rate_pct = if self.stats.total_input_tokens > 0 {
76 tokens_saved as f64 / self.stats.total_input_tokens as f64 * 100.0
77 } else {
78 0.0
79 };
80 let avoided_usd = quote.cost.estimate_usd(tokens_saved, 0, 0, 0);
81 let tool_spend_usd = self.costs.total_cost().max(0.0);
82 let roi = if tool_spend_usd > 0.0 {
83 Some(avoided_usd / tool_spend_usd)
84 } else {
85 None
86 };
87 let score = GainScore::compute(&self.stats, &self.costs, &self.pricing, model);
88 #[cfg(unix)]
89 let daemon_hint = if crate::daemon::is_daemon_running() {
90 None
91 } else {
92 Some(
93 "daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)"
94 .to_string(),
95 )
96 };
97 #[cfg(not(unix))]
98 let daemon_hint: Option<String> = None;
99 GainSummary {
100 model: quote,
101 total_commands: self.stats.total_commands,
102 input_tokens: self.stats.total_input_tokens,
103 output_tokens: self.stats.total_output_tokens,
104 tokens_saved,
105 gain_rate_pct,
106 avoided_usd,
107 tool_spend_usd,
108 roi,
109 score,
110 daemon_hint,
111 }
112 }
113
114 pub fn gain_score(&self, model: Option<&str>) -> GainScore {
115 GainScore::compute(&self.stats, &self.costs, &self.pricing, model)
116 }
117
118 pub fn task_breakdown(&self) -> Vec<TaskGainRow> {
119 use std::collections::HashMap;
120
121 let mut by_cat: HashMap<TaskCategory, TaskGainRow> = HashMap::new();
122
123 for (cmd_key, st) in &self.stats.commands {
124 let cat = TaskClassifier::classify_command_key(cmd_key);
125 let row = by_cat.entry(cat).or_insert(TaskGainRow {
126 category: cat,
127 commands: 0,
128 tokens_saved: 0,
129 tool_calls: 0,
130 tool_spend_usd: 0.0,
131 });
132 row.commands += st.count;
133 row.tokens_saved += st.input_tokens.saturating_sub(st.output_tokens);
134 }
135
136 for (tool, tc) in &self.costs.tools {
137 let cat = TaskClassifier::classify_tool(tool);
138 let row = by_cat.entry(cat).or_insert(TaskGainRow {
139 category: cat,
140 commands: 0,
141 tokens_saved: 0,
142 tool_calls: 0,
143 tool_spend_usd: 0.0,
144 });
145 row.tool_calls += tc.total_calls;
146 row.tool_spend_usd += tc.cost_usd;
147 }
148
149 let mut out: Vec<TaskGainRow> = by_cat.into_values().collect();
150 out.sort_by_key(|x| std::cmp::Reverse(x.tokens_saved));
151 out
152 }
153
154 pub fn heatmap_gains(&self, limit: usize) -> Vec<FileGainRow> {
155 let mut items: Vec<_> = self.heatmap.entries.values().collect();
156 items.sort_by_key(|x| std::cmp::Reverse(x.total_tokens_saved));
157 items.truncate(limit);
158 items
159 .into_iter()
160 .map(|e| FileGainRow {
161 path: e.path.clone(),
162 access_count: e.access_count,
163 tokens_saved: e.total_tokens_saved,
164 compression_pct: e.avg_compression_ratio * 100.0,
165 })
166 .collect()
167 }
168}