lean_ctx/core/gain/
gain_score.rs1use 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 pub trend: Trend,
22}
23
24impl GainScore {
25 pub fn compute(
26 stats: &StatsStore,
27 costs: &CostStore,
28 pricing: &ModelPricing,
29 model: Option<&str>,
30 ) -> Self {
31 let saved_tokens = stats
32 .total_input_tokens
33 .saturating_sub(stats.total_output_tokens);
34 let compression_ratio = if stats.total_input_tokens > 0 {
35 saved_tokens as f64 / stats.total_input_tokens as f64
36 } else {
37 0.0
38 };
39 let compression = pct_to_score(compression_ratio);
40
41 let quote = pricing.quote(model);
42 let avoided_usd = quote.cost.estimate_usd(saved_tokens, 0, 0, 0);
43 let spend_usd = costs.total_cost().max(0.0);
44 let cost_efficiency = roi_to_score(avoided_usd, spend_usd);
45
46 let quality = quality_score(stats);
47 let (consistency, trend) = consistency_and_trend(stats);
48
49 let total = ((compression as u64 * 35
50 + cost_efficiency as u64 * 25
51 + quality as u64 * 20
52 + consistency as u64 * 20)
53 / 100) as u32;
54
55 Self {
56 total,
57 compression,
58 cost_efficiency,
59 quality,
60 consistency,
61 trend,
62 }
63 }
64}
65
66fn pct_to_score(ratio_0_1: f64) -> u32 {
67 if !ratio_0_1.is_finite() || ratio_0_1 <= 0.0 {
68 return 0;
69 }
70 let v = (ratio_0_1 * 100.0).round();
71 v.clamp(0.0, 100.0) as u32
72}
73
74fn roi_to_score(avoided_usd: f64, spend_usd: f64) -> u32 {
75 if avoided_usd <= 0.0 {
76 return 0;
77 }
78 if spend_usd <= 0.0 {
79 return 100;
80 }
81 let roi = avoided_usd / spend_usd;
82 if roi >= 10.0 {
83 return 100;
84 }
85 (roi / 10.0 * 100.0).round().clamp(0.0, 100.0) as u32
86}
87
88fn quality_score(stats: &StatsStore) -> u32 {
89 let cep = &stats.cep;
90
91 let compression = {
92 let saved = stats
93 .total_input_tokens
94 .saturating_sub(stats.total_output_tokens);
95 if stats.total_input_tokens > 0 {
96 saved as f64 / stats.total_input_tokens as f64
97 } else {
98 0.0
99 }
100 };
101
102 let mode_diversity = {
103 let used = cep.modes.len().min(8) as f64;
104 let target = 8f64;
105 (used / target).min(1.0)
106 };
107
108 let tool_breadth = {
109 let total_tool_calls: u64 = cep.modes.values().sum();
110 let mcp_active = total_tool_calls > 0;
111 let shell_active = stats.total_commands > 10;
112 match (mcp_active, shell_active) {
113 (true, true) => 1.0,
114 (true, false) | (false, true) => 0.6,
115 (false, false) => 0.0,
116 }
117 };
118
119 let cache_efficiency = if cep.total_cache_reads > 5 {
120 (cep.total_cache_hits as f64 / cep.total_cache_reads as f64).min(1.0)
121 } else {
122 0.5
123 };
124
125 let q =
126 compression * 0.40 + mode_diversity * 0.25 + tool_breadth * 0.20 + cache_efficiency * 0.15;
127 (q * 100.0).round().clamp(0.0, 100.0) as u32
128}
129
130fn consistency_and_trend(stats: &StatsStore) -> (u32, Trend) {
131 if stats.daily.is_empty() {
132 return (0, Trend::Stable);
133 }
134
135 let n = stats.daily.len();
136 let recent = stats.daily.iter().skip(n.saturating_sub(14));
137 let active_days = recent.filter(|d| d.commands > 0).count() as f64;
138 let consistency = ((active_days / 14.0) * 100.0).round().clamp(0.0, 100.0) as u32;
139
140 let saved_by_day: Vec<u64> = stats
141 .daily
142 .iter()
143 .map(|d| d.input_tokens.saturating_sub(d.output_tokens))
144 .collect();
145
146 let last7: u64 = saved_by_day.iter().rev().take(7).sum();
147 let prev7: u64 = saved_by_day.iter().rev().skip(7).take(7).sum();
148 let trend = if prev7 == 0 && last7 == 0 {
149 Trend::Stable
150 } else if prev7 == 0 && last7 > 0 {
151 Trend::Rising
152 } else {
153 let diff = last7 as f64 - prev7 as f64;
154 let pct = diff / (prev7 as f64).max(1.0);
155 if pct > 0.10 {
156 Trend::Rising
157 } else if pct < -0.10 {
158 Trend::Declining
159 } else {
160 Trend::Stable
161 }
162 };
163
164 (consistency, trend)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn roi_score_bounds() {
173 assert_eq!(roi_to_score(0.0, 10.0), 0);
174 assert_eq!(roi_to_score(10.0, 0.0), 100);
175 assert_eq!(roi_to_score(100.0, 10.0), 100);
176 }
177}