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    if !end_lines.is_empty() || session.stats.total_tool_calls > 3 {
146        end_lines.push(
147            "ctx_read>Read ctx_shell>Shell ctx_search>Grep ctx_tree>ls | Edit/Write/Glob=native"
148                .to_string(),
149        );
150    }
151
152    PositionedOutput {
153        begin_block: begin_lines.join("\n"),
154        end_block: end_lines.join("\n"),
155    }
156}
157
158#[cfg(test)]
159pub fn compute_litm_efficiency(
160    begin_tokens: usize,
161    middle_tokens: usize,
162    end_tokens: usize,
163    ccp_begin_tokens: usize,
164    ccp_end_tokens: usize,
165) -> (f64, f64) {
166    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
167    let effective_without =
168        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
169
170    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
171    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
172
173    let eff_without = if total_without > 0.0 {
174        effective_without / total_without * 100.0
175    } else {
176        0.0
177    };
178    let eff_with = if total_with > 0.0 {
179        effective_with / total_with * 100.0
180    } else {
181        0.0
182    };
183
184    (eff_without, eff_with)
185}
186
187#[cfg(test)]
188pub fn compute_litm_efficiency_for_profile(
189    begin_tokens: usize,
190    middle_tokens: usize,
191    end_tokens: usize,
192    ccp_begin_tokens: usize,
193    ccp_end_tokens: usize,
194    profile: &LitmProfile,
195) -> (f64, f64) {
196    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
197    let effective_without = profile.alpha * begin_tokens as f64
198        + profile.beta * middle_tokens as f64
199        + profile.gamma * end_tokens as f64;
200
201    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
202    let effective_with =
203        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
204
205    let eff_without = if total_without > 0.0 {
206        effective_without / total_without * 100.0
207    } else {
208        0.0
209    };
210    let eff_with = if total_with > 0.0 {
211        effective_with / total_with * 100.0
212    } else {
213        0.0
214    };
215
216    (eff_without, eff_with)
217}
218
219#[cfg(test)]
220pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
221    use crate::core::attention_model;
222
223    let lines: Vec<&str> = content.lines().collect();
224    if lines.is_empty() {
225        return 0.0;
226    }
227
228    let importances: Vec<f64> = lines
229        .iter()
230        .enumerate()
231        .map(|(i, line)| {
232            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
233            attention_model::combined_attention(
234                line,
235                pos,
236                profile.alpha,
237                profile.beta,
238                profile.gamma,
239            )
240        })
241        .collect();
242
243    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
244}
245
246fn short_path(path: &str) -> String {
247    let parts: Vec<&str> = path.split('/').collect();
248    if parts.len() <= 2 {
249        return path.to_string();
250    }
251    parts.last().copied().unwrap_or(path).to_string()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn litm_efficiency_without_ccp_lower() {
260        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
261        assert!(
262            eff_with > eff_without,
263            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
264        );
265    }
266
267    #[test]
268    fn litm_efficiency_zero_tokens() {
269        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
270        assert_eq!(eff_without, 0.0);
271        assert_eq!(eff_with, 0.0);
272    }
273
274    #[test]
275    fn litm_all_at_begin_is_alpha() {
276        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
277        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
278    }
279
280    #[test]
281    fn litm_all_at_end_is_gamma() {
282        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
283        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
284    }
285
286    #[test]
287    fn litm_middle_heavy_is_worst() {
288        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
289        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
290        assert!(
291            eff_balanced > eff_middle,
292            "middle-heavy should be less efficient"
293        );
294    }
295
296    #[test]
297    fn short_path_simple() {
298        assert_eq!(short_path("file.rs"), "file.rs");
299        assert_eq!(short_path("src/file.rs"), "src/file.rs");
300        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
301    }
302
303    #[test]
304    fn litm_profile_from_client_claude() {
305        let p = LitmProfile::from_client_name("Claude Desktop");
306        assert_eq!(p.name, "claude");
307        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
308    }
309
310    #[test]
311    fn litm_profile_from_client_cursor() {
312        let p = LitmProfile::from_client_name("Cursor");
313        assert_eq!(p.name, "claude");
314    }
315
316    #[test]
317    fn litm_profile_from_client_gemini() {
318        let p = LitmProfile::from_client_name("Gemini CLI");
319        assert_eq!(p.name, "gemini");
320        assert!((p.beta - 0.60).abs() < f64::EPSILON);
321    }
322
323    #[test]
324    fn litm_profile_unknown_defaults_to_gpt() {
325        let p = LitmProfile::from_client_name("unknown-tool");
326        assert_eq!(p.name, "gpt");
327    }
328
329    #[test]
330    fn litm_profile_efficiency_differs_by_model() {
331        let (_, claude_eff) =
332            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
333        let (_, gemini_eff) =
334            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
335        assert!(
336            (claude_eff - gemini_eff).abs() > 0.1,
337            "different profiles should yield different efficiencies"
338        );
339    }
340}