Skip to main content

lean_ctx/core/
litm.rs

1use crate::core::session::SessionState;
2use crate::core::tokens::count_tokens;
3
4/// Default hard ceiling (tokens) on the re-injected ACTIVE SESSION block (#962).
5/// Generous: a true safety cap that normal sessions never hit, so default output
6/// is unchanged while a pathological session can no longer crowd out the task.
7pub const DEFAULT_ACTIVE_SESSION_BUDGET: usize = 800;
8
9/// Effective ACTIVE SESSION token budget (`LEAN_CTX_ACTIVE_SESSION_BUDGET` overrides).
10#[must_use]
11pub fn active_session_budget() -> usize {
12    std::env::var("LEAN_CTX_ACTIVE_SESSION_BUDGET")
13        .ok()
14        .and_then(|v| v.parse().ok())
15        .filter(|&n| n > 0)
16        .unwrap_or(DEFAULT_ACTIVE_SESSION_BUDGET)
17}
18
19#[derive(Debug, Clone, Copy)]
20pub struct LitmProfile {
21    pub alpha: f64,
22    pub beta: f64,
23    pub gamma: f64,
24    pub name: &'static str,
25}
26
27impl LitmProfile {
28    pub const CLAUDE: Self = Self {
29        alpha: 0.92,
30        beta: 0.50,
31        gamma: 0.88,
32        name: "claude",
33    };
34    pub const GPT: Self = Self {
35        alpha: 0.90,
36        beta: 0.55,
37        gamma: 0.85,
38        name: "gpt",
39    };
40    pub const GEMINI: Self = Self {
41        alpha: 0.88,
42        beta: 0.60,
43        gamma: 0.82,
44        name: "gemini",
45    };
46    pub const DEFAULT: Self = Self::GPT;
47
48    pub fn from_client_name(client: &str) -> Self {
49        if let Ok(override_val) = std::env::var("LEAN_CTX_LITM_PROFILE") {
50            return Self::from_name(&override_val);
51        }
52        let lower = client.to_lowercase();
53        if lower.contains("claude") || lower.contains("cursor") {
54            Self::CLAUDE
55        } else if lower.contains("gemini") {
56            Self::GEMINI
57        } else {
58            Self::GPT
59        }
60    }
61
62    pub fn from_name(name: &str) -> Self {
63        match name.to_lowercase().as_str() {
64            "claude" | "codebuddy" | "cursor" => Self::CLAUDE,
65            "gemini" => Self::GEMINI,
66            "gpt" | "openai" | "codex" => Self::GPT,
67            _ => Self::DEFAULT,
68        }
69    }
70}
71
72#[cfg(test)]
73const _ALPHA: f64 = 0.9;
74#[cfg(test)]
75const _BETA: f64 = 0.55;
76#[cfg(test)]
77const _GAMMA: f64 = 0.85;
78
79pub struct PositionedOutput {
80    pub begin_block: String,
81    pub end_block: String,
82}
83
84impl PositionedOutput {
85    /// Deterministic hard cap on the re-injected ACTIVE SESSION block (#962).
86    /// Session memory rides every turn, so an unbounded block crowds out the
87    /// user's actual task. The begin block keeps priority over the end block;
88    /// within each, the least-critical lines (rendered last) drop first.
89    pub fn enforce_token_budget(&mut self, budget: usize) {
90        self.begin_block = trim_lines_to_budget(&self.begin_block, budget);
91        let remaining = budget.saturating_sub(count_tokens(&self.begin_block));
92        self.end_block = trim_lines_to_budget(&self.end_block, remaining);
93    }
94}
95
96/// Keeps whole leading lines until the next would exceed `budget`. Deterministic.
97fn trim_lines_to_budget(block: &str, budget: usize) -> String {
98    if block.is_empty() || count_tokens(block) <= budget {
99        return block.to_string();
100    }
101    let mut kept: Vec<&str> = Vec::new();
102    let mut used = 0usize;
103    for line in block.lines() {
104        let cost = count_tokens(line);
105        if used + cost > budget {
106            break;
107        }
108        used += cost;
109        kept.push(line);
110    }
111    kept.join("\n")
112}
113
114/// Sorts session state fields by attention priority:
115///   P1 (begin): task, decisions, project topology, file refs
116///   P2 (end): recent findings, test results, next steps
117///   P3 (dropped): old completed tasks, historical reads beyond limit
118pub fn position_optimize(session: &SessionState) -> PositionedOutput {
119    position_optimize_with_share(session, crate::core::litm_calibration::DEFAULT_BEGIN_SHARE)
120}
121
122/// Calibrated variant (#539): `begin_share` < 0.6 means the begin position
123/// empirically under-performs for this client — progress moves to the end
124/// block and the task line is duplicated there (recency rescue for the most
125/// critical item). At the default share the layout is byte-identical to the
126/// uncalibrated version.
127pub fn position_optimize_with_share(session: &SessionState, begin_share: f64) -> PositionedOutput {
128    let begin_weak = begin_share < 0.6;
129    let mut begin_lines = Vec::new();
130    let mut end_lines = Vec::new();
131
132    // Stable prefix for LLM prefix-cache compatibility:
133    // project root and file refs rarely change, keeping the prefix stable.
134    if let Some(ref root) = session.project_root {
135        begin_lines.push(format!("Root: {root}"));
136    }
137
138    if let Some(ref task) = session.task {
139        let pct = task
140            .progress_pct
141            .map_or(String::new(), |p| format!(" [{p}%]"));
142        begin_lines.push(format!("Task: {}{pct}", task.description));
143    }
144
145    if !session.decisions.is_empty() {
146        let items: Vec<&str> = session
147            .decisions
148            .iter()
149            .rev()
150            .take(5)
151            .map(|d| d.summary.as_str())
152            .collect();
153        begin_lines.push(format!("Decisions: {}", items.join(" | ")));
154    }
155
156    if !session.files_touched.is_empty() {
157        let items: Vec<String> = session
158            .files_touched
159            .iter()
160            .rev()
161            .take(15)
162            .map(|f| {
163                let r = f.file_ref.as_deref().unwrap_or("?");
164                let status = if f.modified { "mod" } else { &f.last_mode };
165                let summary_hint = f
166                    .summary
167                    .as_deref()
168                    .map_or(String::new(), |s| format!(", \"{s}\""));
169                format!("{r}={} [{status}{summary_hint}]", short_path(&f.path))
170            })
171            .collect();
172        begin_lines.push(format!("Files: {}", items.join(" ")));
173    }
174
175    // Progress entries (recent work done). When the begin position is
176    // calibrated as weak (#539) this least-critical begin item moves to the
177    // end block instead.
178    if !session.progress.is_empty() {
179        let items: Vec<String> = session
180            .progress
181            .iter()
182            .rev()
183            .take(5)
184            .map(|p| {
185                p.detail
186                    .as_deref()
187                    .map_or_else(|| p.action.clone(), |d| format!("{}: {d}", p.action))
188            })
189            .collect();
190        let line = format!("Progress: {}", items.join(" | "));
191        if begin_weak {
192            end_lines.push(line);
193        } else {
194            begin_lines.push(line);
195        }
196    }
197
198    if !session.findings.is_empty() {
199        let items: Vec<String> = session
200            .findings
201            .iter()
202            .rev()
203            .take(8)
204            .map(|f| f.summary.clone())
205            .collect();
206        end_lines.push(format!("Findings: {}", items.join(" | ")));
207    }
208
209    if let Some(ref tests) = session.test_results {
210        let status = if tests.failed > 0 { "FAIL" } else { "PASS" };
211        end_lines.push(format!(
212            "Tests [{status}]: {}/{} ({})",
213            tests.passed, tests.total, tests.command
214        ));
215    }
216
217    if !session.next_steps.is_empty() {
218        end_lines.push(format!("Next: {}", session.next_steps.join(" → ")));
219    }
220
221    // Recency rescue (#539): when begin is weak, repeat the task line at the
222    // end — ~10 tokens to keep the single most critical item in the strong
223    // position for this client.
224    if begin_weak && let Some(ref task) = session.task {
225        end_lines.push(format!("Task (active): {}", task.description));
226    }
227
228    // Session stats at end — changes every call, placing here preserves prefix-cache stability
229    end_lines.push(format!(
230        "ACTIVE SESSION v{} | {} calls | {} tok saved",
231        session.version, session.stats.total_tool_calls, session.stats.total_tokens_saved
232    ));
233
234    PositionedOutput {
235        begin_block: begin_lines.join("\n"),
236        end_block: end_lines.join("\n"),
237    }
238}
239
240#[cfg(test)]
241pub fn compute_litm_efficiency(
242    begin_tokens: usize,
243    middle_tokens: usize,
244    end_tokens: usize,
245    ccp_begin_tokens: usize,
246    ccp_end_tokens: usize,
247) -> (f64, f64) {
248    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
249    let effective_without =
250        _ALPHA * begin_tokens as f64 + _BETA * middle_tokens as f64 + _GAMMA * end_tokens as f64;
251
252    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
253    let effective_with = _ALPHA * ccp_begin_tokens as f64 + _GAMMA * ccp_end_tokens as f64;
254
255    let eff_without = if total_without > 0.0 {
256        effective_without / total_without * 100.0
257    } else {
258        0.0
259    };
260    let eff_with = if total_with > 0.0 {
261        effective_with / total_with * 100.0
262    } else {
263        0.0
264    };
265
266    (eff_without, eff_with)
267}
268
269#[cfg(test)]
270pub fn compute_litm_efficiency_for_profile(
271    begin_tokens: usize,
272    middle_tokens: usize,
273    end_tokens: usize,
274    ccp_begin_tokens: usize,
275    ccp_end_tokens: usize,
276    profile: &LitmProfile,
277) -> (f64, f64) {
278    let total_without = (begin_tokens + middle_tokens + end_tokens) as f64;
279    let effective_without = profile.alpha * begin_tokens as f64
280        + profile.beta * middle_tokens as f64
281        + profile.gamma * end_tokens as f64;
282
283    let total_with = (ccp_begin_tokens + ccp_end_tokens) as f64;
284    let effective_with =
285        profile.alpha * ccp_begin_tokens as f64 + profile.gamma * ccp_end_tokens as f64;
286
287    let eff_without = if total_without > 0.0 {
288        effective_without / total_without * 100.0
289    } else {
290        0.0
291    };
292    let eff_with = if total_with > 0.0 {
293        effective_with / total_with * 100.0
294    } else {
295        0.0
296    };
297
298    (eff_without, eff_with)
299}
300
301#[cfg(test)]
302pub fn content_attention_efficiency(content: &str, profile: &LitmProfile) -> f64 {
303    use crate::core::attention_model;
304
305    let lines: Vec<&str> = content.lines().collect();
306    if lines.is_empty() {
307        return 0.0;
308    }
309
310    let importances: Vec<f64> = lines
311        .iter()
312        .enumerate()
313        .map(|(i, line)| {
314            let pos = i as f64 / (lines.len() - 1).max(1) as f64;
315            attention_model::combined_attention(
316                line,
317                pos,
318                profile.alpha,
319                profile.beta,
320                profile.gamma,
321            )
322        })
323        .collect();
324
325    attention_model::attention_efficiency(&importances, profile.alpha, profile.beta, profile.gamma)
326}
327
328fn short_path(path: &str) -> String {
329    let parts: Vec<&str> = path.split('/').collect();
330    if parts.len() <= 2 {
331        return path.to_string();
332    }
333    parts.last().copied().unwrap_or(path).to_string()
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn litm_efficiency_without_ccp_lower() {
342        let (eff_without, eff_with) = compute_litm_efficiency(100, 500, 100, 300, 200);
343        assert!(
344            eff_with > eff_without,
345            "CCP should improve LITM efficiency: without={eff_without:.1}%, with={eff_with:.1}%"
346        );
347    }
348
349    #[test]
350    fn litm_efficiency_zero_tokens() {
351        let (eff_without, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 0);
352        assert_eq!(eff_without, 0.0);
353        assert_eq!(eff_with, 0.0);
354    }
355
356    #[test]
357    fn litm_all_at_begin_is_alpha() {
358        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 100, 0);
359        assert!((eff_with - 90.0).abs() < 0.1, "all begin should be ~90%");
360    }
361
362    #[test]
363    fn litm_all_at_end_is_gamma() {
364        let (_, eff_with) = compute_litm_efficiency(0, 0, 0, 0, 100);
365        assert!((eff_with - 85.0).abs() < 0.1, "all end should be ~85%");
366    }
367
368    #[test]
369    fn litm_middle_heavy_is_worst() {
370        let (eff_middle, _) = compute_litm_efficiency(10, 1000, 10, 0, 0);
371        let (eff_balanced, _) = compute_litm_efficiency(500, 20, 500, 0, 0);
372        assert!(
373            eff_balanced > eff_middle,
374            "middle-heavy should be less efficient"
375        );
376    }
377
378    #[test]
379    fn calibrated_share_moves_progress_to_end() {
380        let mut session = SessionState::new();
381        session.task = Some(crate::core::session::TaskInfo {
382            description: "fix webhook".to_string(),
383            intent: None,
384            progress_pct: None,
385        });
386        session.progress.push(crate::core::session::ProgressEntry {
387            action: "deployed billing".to_string(),
388            detail: None,
389            timestamp: chrono::Utc::now(),
390        });
391
392        let default_layout = position_optimize_with_share(&session, 0.7);
393        assert!(default_layout.begin_block.contains("Progress:"));
394        assert!(!default_layout.end_block.contains("Task (active)"));
395
396        let weak_begin = position_optimize_with_share(&session, 0.45);
397        assert!(!weak_begin.begin_block.contains("Progress:"));
398        assert!(weak_begin.end_block.contains("Progress:"));
399        assert!(weak_begin.end_block.contains("Task (active): fix webhook"));
400    }
401
402    #[test]
403    fn default_share_is_byte_identical_to_uncalibrated() {
404        let mut session = SessionState::new();
405        session.task = Some(crate::core::session::TaskInfo {
406            description: "t".to_string(),
407            intent: None,
408            progress_pct: Some(50),
409        });
410        let a = position_optimize(&session);
411        let b = position_optimize_with_share(
412            &session,
413            crate::core::litm_calibration::DEFAULT_BEGIN_SHARE,
414        );
415        assert_eq!(a.begin_block, b.begin_block);
416        assert_eq!(a.end_block, b.end_block);
417    }
418
419    #[test]
420    fn short_path_simple() {
421        assert_eq!(short_path("file.rs"), "file.rs");
422        assert_eq!(short_path("src/file.rs"), "src/file.rs");
423        assert_eq!(short_path("a/b/c/file.rs"), "file.rs");
424    }
425
426    #[test]
427    fn enforce_token_budget_caps_block_deterministically() {
428        let mut session = SessionState::new();
429        session.project_root = Some("/tmp/x".to_string());
430        session.task = Some(crate::core::session::TaskInfo {
431            description: "deploy ".repeat(200),
432            intent: None,
433            progress_pct: None,
434        });
435
436        let mut a = position_optimize(&session);
437        let before = count_tokens(&a.begin_block);
438        a.enforce_token_budget(8);
439        let after = count_tokens(&a.begin_block);
440        assert!(
441            after <= 8,
442            "begin block must respect the budget, got {after}"
443        );
444        assert!(after < before, "an oversized block must actually shrink");
445
446        let mut b = position_optimize(&session);
447        b.enforce_token_budget(8);
448        assert_eq!(
449            a.begin_block, b.begin_block,
450            "trimming must be deterministic"
451        );
452    }
453
454    #[test]
455    fn enforce_token_budget_is_noop_under_budget() {
456        let mut session = SessionState::new();
457        session.task = Some(crate::core::session::TaskInfo {
458            description: "small task".to_string(),
459            intent: None,
460            progress_pct: Some(50),
461        });
462        let mut out = position_optimize(&session);
463        let original = out.begin_block.clone();
464        out.enforce_token_budget(DEFAULT_ACTIVE_SESSION_BUDGET);
465        assert_eq!(out.begin_block, original, "a small block is left untouched");
466    }
467
468    #[test]
469    fn litm_profile_from_client_claude() {
470        let p = LitmProfile::from_client_name("Claude Desktop");
471        assert_eq!(p.name, "claude");
472        assert!((p.alpha - 0.92).abs() < f64::EPSILON);
473    }
474
475    #[test]
476    fn litm_profile_from_client_cursor() {
477        let p = LitmProfile::from_client_name("Cursor");
478        assert_eq!(p.name, "claude");
479    }
480
481    #[test]
482    fn litm_profile_from_client_gemini() {
483        let p = LitmProfile::from_client_name("Gemini CLI");
484        assert_eq!(p.name, "gemini");
485        assert!((p.beta - 0.60).abs() < f64::EPSILON);
486    }
487
488    #[test]
489    fn litm_profile_unknown_defaults_to_gpt() {
490        let p = LitmProfile::from_client_name("unknown-tool");
491        assert_eq!(p.name, "gpt");
492    }
493
494    #[test]
495    fn litm_profile_efficiency_differs_by_model() {
496        let (_, claude_eff) =
497            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::CLAUDE);
498        let (_, gemini_eff) =
499            compute_litm_efficiency_for_profile(200, 0, 100, 200, 100, &LitmProfile::GEMINI);
500        assert!(
501            (claude_eff - gemini_eff).abs() > 0.1,
502            "different profiles should yield different efficiencies"
503        );
504    }
505}