1use serde::{Deserialize, Serialize};
2
3use crate::core::a2a::cost_attribution::CostStore;
4use crate::core::gain::model_pricing::ModelPricing;
5use crate::core::stats::StatsStore;
6
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
8pub enum Trend {
9 Rising,
10 Stable,
11 Declining,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct GainScore {
16 pub total: u32,
17 pub compression: u32,
18 pub cost_efficiency: u32,
19 pub quality: u32,
20 pub consistency: u32,
21 #[serde(default)]
27 pub navigability: u32,
28 pub trend: Trend,
29}
30
31impl GainScore {
32 pub fn compute(
37 stats: &StatsStore,
38 costs: &CostStore,
39 pricing: &ModelPricing,
40 model: Option<&str>,
41 navigability: Option<u32>,
42 ) -> Self {
43 let saved_tokens = stats
44 .total_input_tokens
45 .saturating_sub(stats.total_output_tokens);
46 let compression_ratio = if stats.total_input_tokens > 0 {
47 saved_tokens as f64 / stats.total_input_tokens as f64
48 } else {
49 0.0
50 };
51 let compression = pct_to_score(compression_ratio);
52
53 let quote = pricing.quote(model);
54 let avoided_usd = quote.cost.estimate_usd(saved_tokens, 0, 0, 0);
55 let spend_usd = costs.total_cost().max(0.0);
56 let cost_efficiency = roi_to_score(avoided_usd, spend_usd);
57
58 let quality = quality_score(stats);
59 let (consistency, trend) = consistency_and_trend(stats);
60
61 let total = match navigability {
62 Some(nav) => {
65 ((compression as u64 * 30
66 + cost_efficiency as u64 * 25
67 + quality as u64 * 15
68 + consistency as u64 * 15
69 + nav as u64 * 15)
70 / 100) as u32
71 }
72 None => {
74 ((compression as u64 * 35
75 + cost_efficiency as u64 * 25
76 + quality as u64 * 20
77 + consistency as u64 * 20)
78 / 100) as u32
79 }
80 };
81
82 Self {
83 total,
84 compression,
85 cost_efficiency,
86 quality,
87 consistency,
88 navigability: navigability.unwrap_or(0),
89 trend,
90 }
91 }
92}
93
94fn pct_to_score(ratio_0_1: f64) -> u32 {
95 if !ratio_0_1.is_finite() || ratio_0_1 <= 0.0 {
96 return 0;
97 }
98 let v = (ratio_0_1 * 100.0).round();
99 v.clamp(0.0, 100.0) as u32
100}
101
102fn roi_to_score(avoided_usd: f64, spend_usd: f64) -> u32 {
103 if avoided_usd <= 0.0 {
104 return 0;
105 }
106 if spend_usd <= 0.0 {
107 return 100;
108 }
109 let roi = avoided_usd / spend_usd;
110 if roi >= 10.0 {
111 return 100;
112 }
113 (roi / 10.0 * 100.0).round().clamp(0.0, 100.0) as u32
114}
115
116fn quality_score(stats: &StatsStore) -> u32 {
117 let cep = &stats.cep;
118
119 let compression = {
120 let saved = stats
121 .total_input_tokens
122 .saturating_sub(stats.total_output_tokens);
123 if stats.total_input_tokens > 0 {
124 saved as f64 / stats.total_input_tokens as f64
125 } else {
126 0.0
127 }
128 };
129
130 let mode_diversity = {
131 let used = cep.modes.len().min(8) as f64;
132 let target = 8f64;
133 (used / target).min(1.0)
134 };
135
136 let tool_breadth = {
137 let total_tool_calls: u64 = cep.modes.values().sum();
138 let mcp_active = total_tool_calls > 0;
139 let shell_active = stats.total_commands > 10;
140 match (mcp_active, shell_active) {
141 (true, true) => 1.0,
142 (true, false) | (false, true) => 0.6,
143 (false, false) => 0.0,
144 }
145 };
146
147 let cache_efficiency = if cep.total_cache_reads > 5 {
148 (cep.total_cache_hits as f64 / cep.total_cache_reads as f64).min(1.0)
149 } else {
150 0.5
151 };
152
153 let q =
154 compression * 0.40 + mode_diversity * 0.25 + tool_breadth * 0.20 + cache_efficiency * 0.15;
155 (q * 100.0).round().clamp(0.0, 100.0) as u32
156}
157
158fn consistency_and_trend(stats: &StatsStore) -> (u32, Trend) {
159 if stats.daily.is_empty() {
160 return (0, Trend::Stable);
161 }
162
163 let n = stats.daily.len();
164 let recent = stats.daily.iter().skip(n.saturating_sub(14));
165 let active_days = recent.filter(|d| d.commands > 0).count() as f64;
166 let consistency = ((active_days / 14.0) * 100.0).round().clamp(0.0, 100.0) as u32;
167
168 let saved_by_day: Vec<u64> = stats
169 .daily
170 .iter()
171 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
172 .collect();
173
174 let last7: u64 = saved_by_day.iter().rev().take(7).sum();
175 let prev7: u64 = saved_by_day.iter().rev().skip(7).take(7).sum();
176 let trend = if prev7 == 0 && last7 == 0 {
177 Trend::Stable
178 } else if prev7 == 0 && last7 > 0 {
179 Trend::Rising
180 } else {
181 let diff = last7 as f64 - prev7 as f64;
182 let pct = diff / (prev7 as f64).max(1.0);
183 if pct > 0.10 {
184 Trend::Rising
185 } else if pct < -0.10 {
186 Trend::Declining
187 } else {
188 Trend::Stable
189 }
190 };
191
192 (consistency, trend)
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct GainLevel {
198 pub level: u8,
199 pub title: &'static str,
200 pub min_score: u32,
201}
202
203impl GainScore {
204 pub fn level(&self) -> GainLevel {
205 match self.total {
206 81..=100 => GainLevel {
207 level: 5,
208 title: "Grandmaster",
209 min_score: 81,
210 },
211 61..=80 => GainLevel {
212 level: 4,
213 title: "Guardian",
214 min_score: 61,
215 },
216 41..=60 => GainLevel {
217 level: 3,
218 title: "Architect",
219 min_score: 41,
220 },
221 21..=40 => GainLevel {
222 level: 2,
223 title: "Optimizer",
224 min_score: 21,
225 },
226 _ => GainLevel {
227 level: 1,
228 title: "Apprentice",
229 min_score: 0,
230 },
231 }
232 }
233
234 pub fn level_progress(&self) -> f64 {
236 let lvl = self.level();
237 let range_start = lvl.min_score;
238 let range_end = match lvl.level {
239 5 => 100,
240 4 => 80,
241 3 => 60,
242 2 => 40,
243 _ => 20,
244 };
245 let range = (range_end - range_start) as f64;
246 if range == 0.0 {
247 return 1.0;
248 }
249 ((self.total - range_start) as f64 / range).clamp(0.0, 1.0)
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn roi_score_bounds() {
259 assert_eq!(roi_to_score(0.0, 10.0), 0);
260 assert_eq!(roi_to_score(10.0, 0.0), 100);
261 assert_eq!(roi_to_score(100.0, 10.0), 100);
262 }
263
264 #[test]
265 fn level_mapping() {
266 let score = GainScore {
267 total: 75,
268 compression: 80,
269 cost_efficiency: 70,
270 quality: 60,
271 consistency: 90,
272 navigability: 0,
273 trend: Trend::Rising,
274 };
275 let lvl = score.level();
276 assert_eq!(lvl.level, 4);
277 assert_eq!(lvl.title, "Guardian");
278 }
279
280 #[test]
281 fn level_progress_calc() {
282 let score = GainScore {
283 total: 50,
284 compression: 50,
285 cost_efficiency: 50,
286 quality: 50,
287 consistency: 50,
288 navigability: 0,
289 trend: Trend::Stable,
290 };
291 let p = score.level_progress();
292 assert!(p > 0.0 && p < 1.0);
293 }
294
295 #[test]
296 fn navigability_absent_uses_legacy_weighting() {
297 let stats = StatsStore::default();
300 let costs = CostStore::default();
301 let pricing = ModelPricing::load();
302 let none = GainScore::compute(&stats, &costs, &pricing, None, None);
303 let zero = GainScore::compute(&stats, &costs, &pricing, None, Some(0));
304 assert_eq!(none.navigability, 0);
307 assert_eq!(zero.navigability, 0);
308 assert_eq!(none.total, zero.total);
309 }
310
311 #[test]
312 fn navigability_present_lifts_total() {
313 let stats = StatsStore {
317 total_input_tokens: 1000,
318 total_output_tokens: 100,
319 ..Default::default()
320 };
321 let costs = CostStore::default();
322 let pricing = ModelPricing::load();
323 let without = GainScore::compute(&stats, &costs, &pricing, None, None);
324 let with = GainScore::compute(&stats, &costs, &pricing, None, Some(100));
325 assert_eq!(with.navigability, 100);
326 assert!(
327 with.total >= without.total,
328 "navigability=100 must not lower total: {} vs {}",
329 with.total,
330 without.total
331 );
332 }
333}