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    // Stable prefix for LLM prefix-cache compatibility:
77    // project root and file refs rarely change, keeping the prefix stable.
78    if let Some(ref root) = session.project_root {
79        begin_lines.push(format!("Root: {root}"));
80    }
81
82    if let Some(ref task) = session.task {
83        let pct = task
84            .progress_pct
85            .map_or(String::new(), |p| format!(" [{p}%]"));
86        begin_lines.push(format!("Task: {}{pct}", task.description));
87    }
88
89    if !session.decisions.is_empty() {
90        let items: Vec<&str> = session
91            .decisions
92            .iter()
93            .rev()
94            .take(5)
95            .map(|d| d.summary.as_str())
96            .collect();
97        begin_lines.push(format!("Decisions: {}", items.join(" | ")));
98    }
99
100    if !session.files_touched.is_empty() {
101        let items: Vec<String> = session
102            .files_touched
103            .iter()
104            .rev()
105            .take(15)
106            .map(|f| {
107                let r = f.file_ref.as_deref().unwrap_or("?");
108                let status = if f.modified { "mod" } else { &f.last_mode };
109                let summary_hint = f
110                    .summary
111                    .as_deref()
112                    .map_or(String::new(), |s| format!(", \"{s}\""));
113                format!("{r}={} [{status}{summary_hint}]", short_path(&f.path))
114            })
115            .collect();
116        begin_lines.push(format!("Files: {}", items.join(" ")));
117    }
118
119    // Progress entries (recent work done)
120    if !session.progress.is_empty() {
121        let items: Vec<String> = session
122            .progress
123            .iter()
124            .rev()
125            .take(5)
126            .map(|p| {
127                p.detail
128                    .as_deref()
129                    .map_or_else(|| p.action.clone(), |d| format!("{}: {d}", p.action))
130            })
131            .collect();
132        begin_lines.push(format!("Progress: {}", items.join(" | ")));
133    }
134
135    if !session.findings.is_empty() {
136        let items: Vec<String> = session
137            .findings
138            .iter()
139            .rev()
140            .take(8)
141            .map(|f| f.summary.clone())
142            .collect();
143        end_lines.push(format!("Findings: {}", items.join(" | ")));
144    }
145
146    if let Some(ref tests) = session.test_results {
147        let status = if tests.failed > 0 { "FAIL" } else { "PASS" };
148        end_lines.push(format!(
149            "Tests [{status}]: {}/{} ({})",
150            tests.passed, tests.total, tests.command
151        ));
152    }
153
154    if !session.next_steps.is_empty() {
155        end_lines.push(format!("Next: {}", session.next_steps.join(" → ")));
156    }
157
158    // Session stats at end — changes every call, placing here preserves prefix-cache stability
159    end_lines.push(format!(
160        "ACTIVE SESSION v{} | {} calls | {} tok saved",
161        session.version, session.stats.total_tool_calls, session.stats.total_tokens_saved
162    ));
163
164    PositionedOutput {
165        begin_block: begin_lines.join("\n"),
166        end_block: end_lines.join("\n"),
167    }
168}
169
170#[cfg(test)]
171pub fn compute_litm_efficiency(
172    begin_tokens: usize,
173    middle_tokens: usize,
174    end_tokens: usize,
175    ccp_begin_tokens: usize,
176    ccp_end_tokens: usize,
177) -> (f64, f64) {
178    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
179    let effective_without =
180        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
181
182    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
183    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
184
185    let eff_without = if total_without > 0.0 {
186        effective_without / total_without * 100.0
187    } else {
188        0.0
189    };
190    let eff_with = if total_with > 0.0 {
191        effective_with / total_with * 100.0
192    } else {
193        0.0
194    };
195
196    (eff_without, eff_with)
197}
198
199#[cfg(test)]
200pub fn compute_litm_efficiency_for_profile(
201    begin_tokens: usize,
202    middle_tokens: usize,
203    end_tokens: usize,
204    ccp_begin_tokens: usize,
205    ccp_end_tokens: usize,
206    profile: &LitmProfile,
207) -> (f64, f64) {
208    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
209    let effective_without = profile.alpha * begin_tokens as f64
210        + profile.beta * middle_tokens as f64
211        + profile.gamma * end_tokens as f64;
212
213    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
214    let effective_with =
215        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
216
217    let eff_without = if total_without > 0.0 {
218        effective_without / total_without * 100.0
219    } else {
220        0.0
221    };
222    let eff_with = if total_with > 0.0 {
223        effective_with / total_with * 100.0
224    } else {
225        0.0
226    };
227
228    (eff_without, eff_with)
229}
230
231#[cfg(test)]
232pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
233    use crate::core::attention_model;
234
235    let lines: Vec<&str> = content.lines().collect();
236    if lines.is_empty() {
237        return 0.0;
238    }
239
240    let importances: Vec<f64> = lines
241        .iter()
242        .enumerate()
243        .map(|(i, line)| {
244            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
245            attention_model::combined_attention(
246                line,
247                pos,
248                profile.alpha,
249                profile.beta,
250                profile.gamma,
251            )
252        })
253        .collect();
254
255    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
256}
257
258fn short_path(path: &str) -> String {
259    let parts: Vec<&str> = path.split('/').collect();
260    if parts.len() <= 2 {
261        return path.to_string();
262    }
263    parts.last().copied().unwrap_or(path).to_string()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn litm_efficiency_without_ccp_lower() {
272        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
273        assert!(
274            eff_with > eff_without,
275            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
276        );
277    }
278
279    #[test]
280    fn litm_efficiency_zero_tokens() {
281        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
282        assert_eq!(eff_without, 0.0);
283        assert_eq!(eff_with, 0.0);
284    }
285
286    #[test]
287    fn litm_all_at_begin_is_alpha() {
288        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
289        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
290    }
291
292    #[test]
293    fn litm_all_at_end_is_gamma() {
294        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
295        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
296    }
297
298    #[test]
299    fn litm_middle_heavy_is_worst() {
300        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
301        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
302        assert!(
303            eff_balanced > eff_middle,
304            "middle-heavy should be less efficient"
305        );
306    }
307
308    #[test]
309    fn short_path_simple() {
310        assert_eq!(short_path("file.rs"), "file.rs");
311        assert_eq!(short_path("src/file.rs"), "src/file.rs");
312        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
313    }
314
315    #[test]
316    fn litm_profile_from_client_claude() {
317        let p = LitmProfile::from_client_name("Claude Desktop");
318        assert_eq!(p.name, "claude");
319        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
320    }
321
322    #[test]
323    fn litm_profile_from_client_cursor() {
324        let p = LitmProfile::from_client_name("Cursor");
325        assert_eq!(p.name, "claude");
326    }
327
328    #[test]
329    fn litm_profile_from_client_gemini() {
330        let p = LitmProfile::from_client_name("Gemini CLI");
331        assert_eq!(p.name, "gemini");
332        assert!((p.beta - 0.60).abs() < f64::EPSILON);
333    }
334
335    #[test]
336    fn litm_profile_unknown_defaults_to_gpt() {
337        let p = LitmProfile::from_client_name("unknown-tool");
338        assert_eq!(p.name, "gpt");
339    }
340
341    #[test]
342    fn litm_profile_efficiency_differs_by_model() {
343        let (_, claude_eff) =
344            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
345        let (_, gemini_eff) =
346            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
347        assert!(
348            (claude_eff - gemini_eff).abs() > 0.1,
349            "different profiles should yield different efficiencies"
350        );
351    }
352}