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