Skip to main content

lean_ctx/core/
litm.rs

1use crate::core::session::SessionState;
2
3#[derive(Debug, Clone, Copy)]
4pub struct LitmProfile {
5    pub alpha: f64,
6    pub beta: f64,
7    pub gamma: f64,
8    pub name: &'static str,
9}
10
11impl LitmProfile {
12    pub const CLAUDE: Self = Self {
13        alpha: 0.92,
14        beta: 0.50,
15        gamma: 0.88,
16        name: "claude",
17    };
18    pub const GPT: Self = Self {
19        alpha: 0.90,
20        beta: 0.55,
21        gamma: 0.85,
22        name: "gpt",
23    };
24    pub const GEMINI: Self = Self {
25        alpha: 0.88,
26        beta: 0.60,
27        gamma: 0.82,
28        name: "gemini",
29    };
30    pub const DEFAULT: Self = Self::GPT;
31
32    pub fn from_client_name(client: &str) -> Self {
33        if let Ok(override_val) = std::env::var("LEAN_CTX_LITM_PROFILE") {
34            return Self::from_name(&override_val);
35        }
36        let lower = client.to_lowercase();
37        if lower.contains("claude") || lower.contains("cursor") {
38            Self::CLAUDE
39        } else if lower.contains("gemini") {
40            Self::GEMINI
41        } else {
42            Self::GPT
43        }
44    }
45
46    pub fn from_name(name: &str) -> Self {
47        match name.to_lowercase().as_str() {
48            "claude" | "cursor" => Self::CLAUDE,
49            "gemini" => Self::GEMINI,
50            "gpt" | "openai" | "codex" => Self::GPT,
51            _ => Self::DEFAULT,
52        }
53    }
54}
55
56#[cfg(test)]
57const _ALPHA: f64 = 0.9;
58#[cfg(test)]
59const _BETA: f64 = 0.55;
60#[cfg(test)]
61const _GAMMA: f64 = 0.85;
62
63pub struct PositionedOutput {
64    pub begin_block: String,
65    pub end_block: String,
66}
67
68/// Sorts session state fields by attention priority:
69///   P1 (begin): task, decisions, project topology, file refs
70///   P2 (end): recent findings, test results, next steps
71///   P3 (dropped): old completed tasks, historical reads beyond limit
72pub fn position_optimize(session: &SessionState) -> PositionedOutput {
73    let mut begin_lines = Vec::new();
74    let mut end_lines = Vec::new();
75
76    begin_lines.push(format!(
77        "ACTIVE SESSION v{} | {} calls | {} tok saved",
78        session.version, session.stats.total_tool_calls, session.stats.total_tokens_saved
79    ));
80
81    if let Some(ref task) = session.task {
82        let pct = task
83            .progress_pct
84            .map_or(String::new(), |p| format!(" [{p}%]"));
85        begin_lines.push(format!("Task: {}{pct}", task.description));
86    }
87
88    if let Some(ref root) = session.project_root {
89        begin_lines.push(format!("Root: {root}"));
90    }
91
92    if !session.decisions.is_empty() {
93        let items: Vec<&str> = session
94            .decisions
95            .iter()
96            .rev()
97            .take(5)
98            .map(|d| d.summary.as_str())
99            .collect();
100        begin_lines.push(format!("Decisions: {}", items.join(" | ")));
101    }
102
103    if !session.files_touched.is_empty() {
104        let items: Vec<String> = session
105            .files_touched
106            .iter()
107            .rev()
108            .take(15)
109            .map(|f| {
110                let r = f.file_ref.as_deref().unwrap_or("?");
111                let status = if f.modified { "mod" } else { &f.last_mode };
112                format!("{r}={} [{status}]", short_path(&f.path))
113            })
114            .collect();
115        begin_lines.push(format!("Files: {}", items.join(" ")));
116    }
117
118    if !session.findings.is_empty() {
119        let items: Vec<String> = session
120            .findings
121            .iter()
122            .rev()
123            .take(5)
124            .map(|f| match (&f.file, f.line) {
125                (Some(file), Some(line)) => format!("{}:{line} — {}", short_path(file), f.summary),
126                (Some(file), None) => format!("{} — {}", short_path(file), f.summary),
127                _ => f.summary.clone(),
128            })
129            .collect();
130        end_lines.push(format!("Findings: {}", items.join(" | ")));
131    }
132
133    if let Some(ref tests) = session.test_results {
134        let status = if tests.failed > 0 { "FAIL" } else { "PASS" };
135        end_lines.push(format!(
136            "Tests [{status}]: {}/{} ({})",
137            tests.passed, tests.total, tests.command
138        ));
139    }
140
141    if !session.next_steps.is_empty() {
142        end_lines.push(format!("Next: {}", session.next_steps.join(" → ")));
143    }
144
145    PositionedOutput {
146        begin_block: begin_lines.join("\n"),
147        end_block: end_lines.join("\n"),
148    }
149}
150
151#[cfg(test)]
152pub fn compute_litm_efficiency(
153    begin_tokens: usize,
154    middle_tokens: usize,
155    end_tokens: usize,
156    ccp_begin_tokens: usize,
157    ccp_end_tokens: usize,
158) -> (f64, f64) {
159    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
160    let effective_without =
161        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
162
163    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
164    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
165
166    let eff_without = if total_without > 0.0 {
167        effective_without / total_without * 100.0
168    } else {
169        0.0
170    };
171    let eff_with = if total_with > 0.0 {
172        effective_with / total_with * 100.0
173    } else {
174        0.0
175    };
176
177    (eff_without, eff_with)
178}
179
180#[cfg(test)]
181pub fn compute_litm_efficiency_for_profile(
182    begin_tokens: usize,
183    middle_tokens: usize,
184    end_tokens: usize,
185    ccp_begin_tokens: usize,
186    ccp_end_tokens: usize,
187    profile: &LitmProfile,
188) -> (f64, f64) {
189    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
190    let effective_without = profile.alpha * begin_tokens as f64
191        + profile.beta * middle_tokens as f64
192        + profile.gamma * end_tokens as f64;
193
194    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
195    let effective_with =
196        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
197
198    let eff_without = if total_without > 0.0 {
199        effective_without / total_without * 100.0
200    } else {
201        0.0
202    };
203    let eff_with = if total_with > 0.0 {
204        effective_with / total_with * 100.0
205    } else {
206        0.0
207    };
208
209    (eff_without, eff_with)
210}
211
212#[cfg(test)]
213pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
214    use crate::core::attention_model;
215
216    let lines: Vec<&str> = content.lines().collect();
217    if lines.is_empty() {
218        return 0.0;
219    }
220
221    let importances: Vec<f64> = lines
222        .iter()
223        .enumerate()
224        .map(|(i, line)| {
225            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
226            attention_model::combined_attention(
227                line,
228                pos,
229                profile.alpha,
230                profile.beta,
231                profile.gamma,
232            )
233        })
234        .collect();
235
236    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
237}
238
239fn short_path(path: &str) -> String {
240    let parts: Vec<&str> = path.split('/').collect();
241    if parts.len() <= 2 {
242        return path.to_string();
243    }
244    parts.last().copied().unwrap_or(path).to_string()
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn litm_efficiency_without_ccp_lower() {
253        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
254        assert!(
255            eff_with > eff_without,
256            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
257        );
258    }
259
260    #[test]
261    fn litm_efficiency_zero_tokens() {
262        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
263        assert_eq!(eff_without, 0.0);
264        assert_eq!(eff_with, 0.0);
265    }
266
267    #[test]
268    fn litm_all_at_begin_is_alpha() {
269        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
270        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
271    }
272
273    #[test]
274    fn litm_all_at_end_is_gamma() {
275        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
276        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
277    }
278
279    #[test]
280    fn litm_middle_heavy_is_worst() {
281        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
282        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
283        assert!(
284            eff_balanced > eff_middle,
285            "middle-heavy should be less efficient"
286        );
287    }
288
289    #[test]
290    fn short_path_simple() {
291        assert_eq!(short_path("file.rs"), "file.rs");
292        assert_eq!(short_path("src/file.rs"), "src/file.rs");
293        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
294    }
295
296    #[test]
297    fn litm_profile_from_client_claude() {
298        let p = LitmProfile::from_client_name("Claude Desktop");
299        assert_eq!(p.name, "claude");
300        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
301    }
302
303    #[test]
304    fn litm_profile_from_client_cursor() {
305        let p = LitmProfile::from_client_name("Cursor");
306        assert_eq!(p.name, "claude");
307    }
308
309    #[test]
310    fn litm_profile_from_client_gemini() {
311        let p = LitmProfile::from_client_name("Gemini CLI");
312        assert_eq!(p.name, "gemini");
313        assert!((p.beta - 0.60).abs() < f64::EPSILON);
314    }
315
316    #[test]
317    fn litm_profile_unknown_defaults_to_gpt() {
318        let p = LitmProfile::from_client_name("unknown-tool");
319        assert_eq!(p.name, "gpt");
320    }
321
322    #[test]
323    fn litm_profile_efficiency_differs_by_model() {
324        let (_, claude_eff) =
325            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
326        let (_, gemini_eff) =
327            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
328        assert!(
329            (claude_eff - gemini_eff).abs() > 0.1,
330            "different profiles should yield different efficiencies"
331        );
332    }
333}