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" | "codebuddy" | "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    position_optimize_with_share(session, crate::core::litm_calibration::DEFAULT_BEGIN_SHARE)
74}
75
76/// Calibrated variant (#539): `begin_share` < 0.6 means the begin position
77/// empirically under-performs for this client — progress moves to the end
78/// block and the task line is duplicated there (recency rescue for the most
79/// critical item). At the default share the layout is byte-identical to the
80/// uncalibrated version.
81pub fn position_optimize_with_share(session: &SessionState, begin_share: f64) -> PositionedOutput {
82    let begin_weak = begin_share < 0.6;
83    let mut begin_lines = Vec::new();
84    let mut end_lines = Vec::new();
85
86    // Stable prefix for LLM prefix-cache compatibility:
87    // project root and file refs rarely change, keeping the prefix stable.
88    if let Some(ref root) = session.project_root {
89        begin_lines.push(format!("Root: {root}"));
90    }
91
92    if let Some(ref task) = session.task {
93        let pct = task
94            .progress_pct
95            .map_or(String::new(), |p| format!(" [{p}%]"));
96        begin_lines.push(format!("Task: {}{pct}", task.description));
97    }
98
99    if !session.decisions.is_empty() {
100        let items: Vec<&str> = session
101            .decisions
102            .iter()
103            .rev()
104            .take(5)
105            .map(|d| d.summary.as_str())
106            .collect();
107        begin_lines.push(format!("Decisions: {}", items.join(" | ")));
108    }
109
110    if !session.files_touched.is_empty() {
111        let items: Vec<String> = session
112            .files_touched
113            .iter()
114            .rev()
115            .take(15)
116            .map(|f| {
117                let r = f.file_ref.as_deref().unwrap_or("?");
118                let status = if f.modified { "mod" } else { &f.last_mode };
119                let summary_hint = f
120                    .summary
121                    .as_deref()
122                    .map_or(String::new(), |s| format!(", \"{s}\""));
123                format!("{r}={} [{status}{summary_hint}]", short_path(&f.path))
124            })
125            .collect();
126        begin_lines.push(format!("Files: {}", items.join(" ")));
127    }
128
129    // Progress entries (recent work done). When the begin position is
130    // calibrated as weak (#539) this least-critical begin item moves to the
131    // end block instead.
132    if !session.progress.is_empty() {
133        let items: Vec<String> = session
134            .progress
135            .iter()
136            .rev()
137            .take(5)
138            .map(|p| {
139                p.detail
140                    .as_deref()
141                    .map_or_else(|| p.action.clone(), |d| format!("{}: {d}", p.action))
142            })
143            .collect();
144        let line = format!("Progress: {}", items.join(" | "));
145        if begin_weak {
146            end_lines.push(line);
147        } else {
148            begin_lines.push(line);
149        }
150    }
151
152    if !session.findings.is_empty() {
153        let items: Vec<String> = session
154            .findings
155            .iter()
156            .rev()
157            .take(8)
158            .map(|f| f.summary.clone())
159            .collect();
160        end_lines.push(format!("Findings: {}", items.join(" | ")));
161    }
162
163    if let Some(ref tests) = session.test_results {
164        let status = if tests.failed > 0 { "FAIL" } else { "PASS" };
165        end_lines.push(format!(
166            "Tests [{status}]: {}/{} ({})",
167            tests.passed, tests.total, tests.command
168        ));
169    }
170
171    if !session.next_steps.is_empty() {
172        end_lines.push(format!("Next: {}", session.next_steps.join(" → ")));
173    }
174
175    // Recency rescue (#539): when begin is weak, repeat the task line at the
176    // end — ~10 tokens to keep the single most critical item in the strong
177    // position for this client.
178    if begin_weak && let Some(ref task) = session.task {
179        end_lines.push(format!("Task (active): {}", task.description));
180    }
181
182    // Session stats at end — changes every call, placing here preserves prefix-cache stability
183    end_lines.push(format!(
184        "ACTIVE SESSION v{} | {} calls | {} tok saved",
185        session.version, session.stats.total_tool_calls, session.stats.total_tokens_saved
186    ));
187
188    PositionedOutput {
189        begin_block: begin_lines.join("\n"),
190        end_block: end_lines.join("\n"),
191    }
192}
193
194#[cfg(test)]
195pub fn compute_litm_efficiency(
196    begin_tokens: usize,
197    middle_tokens: usize,
198    end_tokens: usize,
199    ccp_begin_tokens: usize,
200    ccp_end_tokens: usize,
201) -> (f64, f64) {
202    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
203    let effective_without =
204        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
205
206    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
207    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
208
209    let eff_without = if total_without > 0.0 {
210        effective_without / total_without * 100.0
211    } else {
212        0.0
213    };
214    let eff_with = if total_with > 0.0 {
215        effective_with / total_with * 100.0
216    } else {
217        0.0
218    };
219
220    (eff_without, eff_with)
221}
222
223#[cfg(test)]
224pub fn compute_litm_efficiency_for_profile(
225    begin_tokens: usize,
226    middle_tokens: usize,
227    end_tokens: usize,
228    ccp_begin_tokens: usize,
229    ccp_end_tokens: usize,
230    profile: &LitmProfile,
231) -> (f64, f64) {
232    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
233    let effective_without = profile.alpha * begin_tokens as f64
234        + profile.beta * middle_tokens as f64
235        + profile.gamma * end_tokens as f64;
236
237    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
238    let effective_with =
239        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
240
241    let eff_without = if total_without > 0.0 {
242        effective_without / total_without * 100.0
243    } else {
244        0.0
245    };
246    let eff_with = if total_with > 0.0 {
247        effective_with / total_with * 100.0
248    } else {
249        0.0
250    };
251
252    (eff_without, eff_with)
253}
254
255#[cfg(test)]
256pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
257    use crate::core::attention_model;
258
259    let lines: Vec<&str> = content.lines().collect();
260    if lines.is_empty() {
261        return 0.0;
262    }
263
264    let importances: Vec<f64> = lines
265        .iter()
266        .enumerate()
267        .map(|(i, line)| {
268            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
269            attention_model::combined_attention(
270                line,
271                pos,
272                profile.alpha,
273                profile.beta,
274                profile.gamma,
275            )
276        })
277        .collect();
278
279    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
280}
281
282fn short_path(path: &str) -> String {
283    let parts: Vec<&str> = path.split('/').collect();
284    if parts.len() <= 2 {
285        return path.to_string();
286    }
287    parts.last().copied().unwrap_or(path).to_string()
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn litm_efficiency_without_ccp_lower() {
296        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
297        assert!(
298            eff_with > eff_without,
299            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
300        );
301    }
302
303    #[test]
304    fn litm_efficiency_zero_tokens() {
305        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
306        assert_eq!(eff_without, 0.0);
307        assert_eq!(eff_with, 0.0);
308    }
309
310    #[test]
311    fn litm_all_at_begin_is_alpha() {
312        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
313        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
314    }
315
316    #[test]
317    fn litm_all_at_end_is_gamma() {
318        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
319        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
320    }
321
322    #[test]
323    fn litm_middle_heavy_is_worst() {
324        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
325        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
326        assert!(
327            eff_balanced > eff_middle,
328            "middle-heavy should be less efficient"
329        );
330    }
331
332    #[test]
333    fn calibrated_share_moves_progress_to_end() {
334        let mut session = SessionState::new();
335        session.task = Some(crate::core::session::TaskInfo {
336            description: "fix webhook".to_string(),
337            intent: None,
338            progress_pct: None,
339        });
340        session.progress.push(crate::core::session::ProgressEntry {
341            action: "deployed billing".to_string(),
342            detail: None,
343            timestamp: chrono::Utc::now(),
344        });
345
346        let default_layout = position_optimize_with_share(&session, 0.7);
347        assert!(default_layout.begin_block.contains("Progress:"));
348        assert!(!default_layout.end_block.contains("Task (active)"));
349
350        let weak_begin = position_optimize_with_share(&session, 0.45);
351        assert!(!weak_begin.begin_block.contains("Progress:"));
352        assert!(weak_begin.end_block.contains("Progress:"));
353        assert!(weak_begin.end_block.contains("Task (active): fix webhook"));
354    }
355
356    #[test]
357    fn default_share_is_byte_identical_to_uncalibrated() {
358        let mut session = SessionState::new();
359        session.task = Some(crate::core::session::TaskInfo {
360            description: "t".to_string(),
361            intent: None,
362            progress_pct: Some(50),
363        });
364        let a = position_optimize(&session);
365        let b = position_optimize_with_share(
366            &session,
367            crate::core::litm_calibration::DEFAULT_BEGIN_SHARE,
368        );
369        assert_eq!(a.begin_block, b.begin_block);
370        assert_eq!(a.end_block, b.end_block);
371    }
372
373    #[test]
374    fn short_path_simple() {
375        assert_eq!(short_path("file.rs"), "file.rs");
376        assert_eq!(short_path("src/file.rs"), "src/file.rs");
377        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
378    }
379
380    #[test]
381    fn litm_profile_from_client_claude() {
382        let p = LitmProfile::from_client_name("Claude Desktop");
383        assert_eq!(p.name, "claude");
384        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
385    }
386
387    #[test]
388    fn litm_profile_from_client_cursor() {
389        let p = LitmProfile::from_client_name("Cursor");
390        assert_eq!(p.name, "claude");
391    }
392
393    #[test]
394    fn litm_profile_from_client_gemini() {
395        let p = LitmProfile::from_client_name("Gemini CLI");
396        assert_eq!(p.name, "gemini");
397        assert!((p.beta - 0.60).abs() < f64::EPSILON);
398    }
399
400    #[test]
401    fn litm_profile_unknown_defaults_to_gpt() {
402        let p = LitmProfile::from_client_name("unknown-tool");
403        assert_eq!(p.name, "gpt");
404    }
405
406    #[test]
407    fn litm_profile_efficiency_differs_by_model() {
408        let (_, claude_eff) =
409            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
410        let (_, gemini_eff) =
411            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
412        assert!(
413            (claude_eff - gemini_eff).abs() > 0.1,
414            "different profiles should yield different efficiencies"
415        );
416    }
417}