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    /// Code navigability of the current project (0–100), from the Code Health
22    /// Engine ([`crate::core::code_health`]). `0` when no health data exists
23    /// (engine not yet run / non-project context); in that case it is excluded
24    /// from `total` so users are never penalised for a signal we don't have.
25    /// `#[serde(default)]` keeps older persisted/synced payloads deserialisable.
26    #[serde(default)]
27    pub navigability: u32,
28    pub trend: Trend,
29}
30
31impl GainScore {
32    /// `navigability` is the current project's code-health score (0–100) or
33    /// `None` when unavailable. When present it contributes 15% of `total`
34    /// (sharper code-quality signal); when absent the historical four-component
35    /// weighting is used unchanged (no degradation, #1086).
36    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            // Code-health signal present → 30/25/15/15/15 (compression stays
63            // dominant; navigability shares the quality dimension).
64            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            // No code-health data → historical 35/25/20/20 (unchanged).
73            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/// Level system — maps gain score to a title and level number.
196#[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    /// Progress within the current level (0.0 to 1.0).
235    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        // No code-health data must yield the historical 35/25/20/20 total so
298        // existing users are never penalised for a signal we don't have (#1086).
299        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        // With all-zero behavioural inputs both totals are 0, but the field must
305        // reflect the explicit nav input.
306        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        // A high navigability with the 30/25/15/15/15 split must contribute to
314        // total even when behavioural components are flat. Non-zero token totals
315        // give compression a value so the weighting branch is observable.
316        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}