Skip to main content

lean_ctx/core/gain/
gain_score.rs

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    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/// Level system — maps gain score to a title and level number.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct GainLevel {
170    pub level: u8,
171    pub title: &'static str,
172    pub min_score: u32,
173}
174
175impl GainScore {
176    pub fn level(&self) -> GainLevel {
177        match self.total {
178            81..=100 => GainLevel {
179                level: 5,
180                title: "Grandmaster",
181                min_score: 81,
182            },
183            61..=80 => GainLevel {
184                level: 4,
185                title: "Guardian",
186                min_score: 61,
187            },
188            41..=60 => GainLevel {
189                level: 3,
190                title: "Architect",
191                min_score: 41,
192            },
193            21..=40 => GainLevel {
194                level: 2,
195                title: "Optimizer",
196                min_score: 21,
197            },
198            _ => GainLevel {
199                level: 1,
200                title: "Apprentice",
201                min_score: 0,
202            },
203        }
204    }
205
206    /// Progress within the current level (0.0 to 1.0).
207    pub fn level_progress(&self) -> f64 {
208        let lvl = self.level();
209        let range_start = lvl.min_score;
210        let range_end = match lvl.level {
211            5 => 100,
212            4 => 80,
213            3 => 60,
214            2 => 40,
215            _ => 20,
216        };
217        let range = (range_end - range_start) as f64;
218        if range == 0.0 {
219            return 1.0;
220        }
221        ((self.total - range_start) as f64 / range).clamp(0.0, 1.0)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn roi_score_bounds() {
231        assert_eq!(roi_to_score(0.0, 10.0), 0);
232        assert_eq!(roi_to_score(10.0, 0.0), 100);
233        assert_eq!(roi_to_score(100.0, 10.0), 100);
234    }
235
236    #[test]
237    fn level_mapping() {
238        let score = GainScore {
239            total: 75,
240            compression: 80,
241            cost_efficiency: 70,
242            quality: 60,
243            consistency: 90,
244            trend: Trend::Rising,
245        };
246        let lvl = score.level();
247        assert_eq!(lvl.level, 4);
248        assert_eq!(lvl.title, "Guardian");
249    }
250
251    #[test]
252    fn level_progress_calc() {
253        let score = GainScore {
254            total: 50,
255            compression: 50,
256            cost_efficiency: 50,
257            quality: 50,
258            consistency: 50,
259            trend: Trend::Stable,
260        };
261        let p = score.level_progress();
262        assert!(p > 0.0 && p < 1.0);
263    }
264}