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 {
179        if let Some(ref task) = session.task {
180            end_lines.push(format!("Task (active): {}", task.description));
181        }
182    }
183
184    // Session stats at end — changes every call, placing here preserves prefix-cache stability
185    end_lines.push(format!(
186        "ACTIVE SESSION v{} | {} calls | {} tok saved",
187        session.version, session.stats.total_tool_calls, session.stats.total_tokens_saved
188    ));
189
190    PositionedOutput {
191        begin_block: begin_lines.join("\n"),
192        end_block: end_lines.join("\n"),
193    }
194}
195
196#[cfg(test)]
197pub fn compute_litm_efficiency(
198    begin_tokens: usize,
199    middle_tokens: usize,
200    end_tokens: usize,
201    ccp_begin_tokens: usize,
202    ccp_end_tokens: usize,
203) -> (f64, f64) {
204    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
205    let effective_without =
206        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
207
208    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
209    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
210
211    let eff_without = if total_without > 0.0 {
212        effective_without / total_without * 100.0
213    } else {
214        0.0
215    };
216    let eff_with = if total_with > 0.0 {
217        effective_with / total_with * 100.0
218    } else {
219        0.0
220    };
221
222    (eff_without, eff_with)
223}
224
225#[cfg(test)]
226pub fn compute_litm_efficiency_for_profile(
227    begin_tokens: usize,
228    middle_tokens: usize,
229    end_tokens: usize,
230    ccp_begin_tokens: usize,
231    ccp_end_tokens: usize,
232    profile: &LitmProfile,
233) -> (f64, f64) {
234    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
235    let effective_without = profile.alpha * begin_tokens as f64
236        + profile.beta * middle_tokens as f64
237        + profile.gamma * end_tokens as f64;
238
239    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
240    let effective_with =
241        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
242
243    let eff_without = if total_without > 0.0 {
244        effective_without / total_without * 100.0
245    } else {
246        0.0
247    };
248    let eff_with = if total_with > 0.0 {
249        effective_with / total_with * 100.0
250    } else {
251        0.0
252    };
253
254    (eff_without, eff_with)
255}
256
257#[cfg(test)]
258pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
259    use crate::core::attention_model;
260
261    let lines: Vec<&str> = content.lines().collect();
262    if lines.is_empty() {
263        return 0.0;
264    }
265
266    let importances: Vec<f64> = lines
267        .iter()
268        .enumerate()
269        .map(|(i, line)| {
270            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
271            attention_model::combined_attention(
272                line,
273                pos,
274                profile.alpha,
275                profile.beta,
276                profile.gamma,
277            )
278        })
279        .collect();
280
281    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
282}
283
284fn short_path(path: &str) -> String {
285    let parts: Vec<&str> = path.split('/').collect();
286    if parts.len() <= 2 {
287        return path.to_string();
288    }
289    parts.last().copied().unwrap_or(path).to_string()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn litm_efficiency_without_ccp_lower() {
298        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
299        assert!(
300            eff_with > eff_without,
301            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
302        );
303    }
304
305    #[test]
306    fn litm_efficiency_zero_tokens() {
307        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
308        assert_eq!(eff_without, 0.0);
309        assert_eq!(eff_with, 0.0);
310    }
311
312    #[test]
313    fn litm_all_at_begin_is_alpha() {
314        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
315        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
316    }
317
318    #[test]
319    fn litm_all_at_end_is_gamma() {
320        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
321        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
322    }
323
324    #[test]
325    fn litm_middle_heavy_is_worst() {
326        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
327        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
328        assert!(
329            eff_balanced > eff_middle,
330            "middle-heavy should be less efficient"
331        );
332    }
333
334    #[test]
335    fn calibrated_share_moves_progress_to_end() {
336        let mut session = SessionState::new();
337        session.task = Some(crate::core::session::TaskInfo {
338            description: "fix webhook".to_string(),
339            intent: None,
340            progress_pct: None,
341        });
342        session.progress.push(crate::core::session::ProgressEntry {
343            action: "deployed billing".to_string(),
344            detail: None,
345            timestamp: chrono::Utc::now(),
346        });
347
348        let default_layout = position_optimize_with_share(&session, 0.7);
349        assert!(default_layout.begin_block.contains("Progress:"));
350        assert!(!default_layout.end_block.contains("Task (active)"));
351
352        let weak_begin = position_optimize_with_share(&session, 0.45);
353        assert!(!weak_begin.begin_block.contains("Progress:"));
354        assert!(weak_begin.end_block.contains("Progress:"));
355        assert!(weak_begin.end_block.contains("Task (active): fix webhook"));
356    }
357
358    #[test]
359    fn default_share_is_byte_identical_to_uncalibrated() {
360        let mut session = SessionState::new();
361        session.task = Some(crate::core::session::TaskInfo {
362            description: "t".to_string(),
363            intent: None,
364            progress_pct: Some(50),
365        });
366        let a = position_optimize(&session);
367        let b = position_optimize_with_share(
368            &session,
369            crate::core::litm_calibration::DEFAULT_BEGIN_SHARE,
370        );
371        assert_eq!(a.begin_block, b.begin_block);
372        assert_eq!(a.end_block, b.end_block);
373    }
374
375    #[test]
376    fn short_path_simple() {
377        assert_eq!(short_path("file.rs"), "file.rs");
378        assert_eq!(short_path("src/file.rs"), "src/file.rs");
379        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
380    }
381
382    #[test]
383    fn litm_profile_from_client_claude() {
384        let p = LitmProfile::from_client_name("Claude Desktop");
385        assert_eq!(p.name, "claude");
386        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
387    }
388
389    #[test]
390    fn litm_profile_from_client_cursor() {
391        let p = LitmProfile::from_client_name("Cursor");
392        assert_eq!(p.name, "claude");
393    }
394
395    #[test]
396    fn litm_profile_from_client_gemini() {
397        let p = LitmProfile::from_client_name("Gemini CLI");
398        assert_eq!(p.name, "gemini");
399        assert!((p.beta - 0.60).abs() < f64::EPSILON);
400    }
401
402    #[test]
403    fn litm_profile_unknown_defaults_to_gpt() {
404        let p = LitmProfile::from_client_name("unknown-tool");
405        assert_eq!(p.name, "gpt");
406    }
407
408    #[test]
409    fn litm_profile_efficiency_differs_by_model() {
410        let (_, claude_eff) =
411            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
412        let (_, gemini_eff) =
413            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
414        assert!(
415            (claude_eff - gemini_eff).abs() > 0.1,
416            "different profiles should yield different efficiencies"
417        );
418    }
419}