Skip to main content

lean_ctx/core/code_health/
score.rs

1//! Project navigability score (0–100) and its estimated token-cost
2//! ("quality tax").
3//!
4//! Higher score = easier for an agent to navigate = lower token cost. The score
5//! is a pure function of aggregated structural inputs (how many functions exceed
6//! the cognitive threshold, the worst offender, import cycles) so it is
7//! deterministic and unit-testable. All surfaces (watch, dashboard,
8//! `ctx_metrics`, `ctx_quality`) read this one score.
9
10use serde::{Deserialize, Serialize};
11
12/// A single complexity hotspot surfaced to the agent.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Hotspot {
15    pub file: String,
16    pub symbol: String,
17    /// 1-based start line.
18    pub line: usize,
19    pub cognitive: u32,
20}
21
22/// Project-level navigability summary.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct NavigabilityScore {
25    /// 0–100, higher is healthier.
26    pub score: u32,
27    pub total_functions: usize,
28    pub over_threshold: usize,
29    pub worst_cognitive: u32,
30    pub import_cycles: usize,
31    /// Estimated input-token cost of the excess complexity, in USD.
32    pub estimated_waste_usd: f64,
33    /// Top hotspots, sorted by cognitive complexity descending.
34    pub hotspots: Vec<Hotspot>,
35}
36
37/// Inputs for [`navigability`]. Behavioral inputs (`wasted_tokens`) and pricing
38/// are supplied by the caller so this stays a pure function. `Copy` (all fields
39/// are `Copy`, including the borrowed hotspot slice) so it passes by value
40/// cheaply.
41#[derive(Debug, Clone, Copy)]
42pub struct NavigabilityInputs<'a> {
43    pub functions_total: usize,
44    pub over_threshold: usize,
45    pub worst_cognitive: u32,
46    pub import_cycles: usize,
47    pub wasted_tokens: u64,
48    pub input_price_per_m: f64,
49    pub hotspots: &'a [Hotspot],
50    pub top_n: usize,
51}
52
53/// Compute the navigability score from aggregated inputs. Deterministic.
54pub fn navigability(inputs: NavigabilityInputs) -> NavigabilityScore {
55    let density = if inputs.functions_total == 0 {
56        0.0
57    } else {
58        inputs.over_threshold as f64 / inputs.functions_total as f64
59    };
60
61    let density_penalty = (density * 60.0).min(60.0);
62    let cycle_penalty = (inputs.import_cycles as f64 * 4.0).min(25.0);
63    let severity_penalty = if inputs.worst_cognitive > 15 {
64        (f64::from(inputs.worst_cognitive - 15) * 0.8).min(15.0)
65    } else {
66        0.0
67    };
68
69    let raw = 100.0 - density_penalty - cycle_penalty - severity_penalty;
70    let score = raw.clamp(0.0, 100.0).round() as u32;
71
72    let estimated_waste_usd = inputs.wasted_tokens as f64 / 1_000_000.0 * inputs.input_price_per_m;
73
74    let mut hotspots = inputs.hotspots.to_vec();
75    hotspots.sort_by(|a, b| {
76        b.cognitive
77            .cmp(&a.cognitive)
78            .then_with(|| a.file.cmp(&b.file))
79            .then_with(|| a.line.cmp(&b.line))
80    });
81    hotspots.truncate(inputs.top_n);
82
83    NavigabilityScore {
84        score,
85        total_functions: inputs.functions_total,
86        over_threshold: inputs.over_threshold,
87        worst_cognitive: inputs.worst_cognitive,
88        import_cycles: inputs.import_cycles,
89        estimated_waste_usd,
90        hotspots,
91    }
92}
93
94/// Letter grade for a navigability score, for compact display.
95pub fn grade(score: u32) -> char {
96    match score {
97        90..=100 => 'A',
98        75..=89 => 'B',
99        60..=74 => 'C',
100        40..=59 => 'D',
101        _ => 'F',
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn inputs<'a>(total: usize, over: usize, worst: u32, cycles: usize) -> NavigabilityInputs<'a> {
110        NavigabilityInputs {
111            functions_total: total,
112            over_threshold: over,
113            worst_cognitive: worst,
114            import_cycles: cycles,
115            wasted_tokens: 0,
116            input_price_per_m: 0.0,
117            hotspots: &[],
118            top_n: 5,
119        }
120    }
121
122    #[test]
123    fn clean_project_scores_100() {
124        let s = navigability(inputs(50, 0, 8, 0));
125        assert_eq!(s.score, 100);
126        assert_eq!(grade(s.score), 'A');
127    }
128
129    #[test]
130    fn heavy_complexity_lowers_score() {
131        let clean = navigability(inputs(10, 0, 10, 0)).score;
132        let messy = navigability(inputs(10, 8, 40, 3)).score;
133        assert!(messy < clean);
134        assert!(messy < 60);
135    }
136
137    #[test]
138    fn empty_project_is_not_negative() {
139        let s = navigability(inputs(0, 0, 0, 0));
140        assert_eq!(s.score, 100);
141    }
142
143    #[test]
144    fn waste_usd_uses_input_price() {
145        let mut inp = inputs(10, 2, 20, 0);
146        inp.wasted_tokens = 2_000_000;
147        inp.input_price_per_m = 5.0;
148        let s = navigability(inp);
149        assert!((s.estimated_waste_usd - 10.0).abs() < 1e-9);
150    }
151
152    #[test]
153    fn hotspots_sorted_and_truncated() {
154        let hs = vec![
155            Hotspot {
156                file: "a.rs".into(),
157                symbol: "low".into(),
158                line: 1,
159                cognitive: 16,
160            },
161            Hotspot {
162                file: "b.rs".into(),
163                symbol: "high".into(),
164                line: 2,
165                cognitive: 40,
166            },
167        ];
168        let mut inp = inputs(10, 2, 40, 0);
169        inp.hotspots = &hs;
170        inp.top_n = 1;
171        let s = navigability(inp);
172        assert_eq!(s.hotspots.len(), 1);
173        assert_eq!(s.hotspots[0].symbol, "high");
174    }
175
176    #[test]
177    fn grade_boundaries() {
178        assert_eq!(grade(100), 'A');
179        assert_eq!(grade(89), 'B');
180        assert_eq!(grade(60), 'C');
181        assert_eq!(grade(40), 'D');
182        assert_eq!(grade(0), 'F');
183    }
184}