1use crate::core::session::SessionState;
2use crate::core::tokens::count_tokens;
3
4pub const DEFAULT_ACTIVE_SESSION_BUDGET: usize = 800;
8
9#[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 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
96fn 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
114pub fn position_optimize(session: &SessionState) -> PositionedOutput {
119 position_optimize_with_share(session, crate::core::litm_calibration::DEFAULT_BEGIN_SHARE)
120}
121
122pub 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 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 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 if begin_weak && let Some(ref task) = session.task {
225 end_lines.push(format!("Task (active): {}", task.description));
226 }
227
228 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}