1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::context_field::{
6 ContextItemId, ContextKind, ContextState, Provenance, ViewCosts, ViewKind,
7};
8
9const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
10
11const PHI_REREAD_ALPHA: f64 = 0.5;
15
16const GWT_IGNITION_Z: f64 = 1.5;
20const GWT_MIN_ENTRIES: usize = 4;
24
25fn ledger_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
26 let dir = crate::core::paths::state_dir()?;
27 if agent_id == "default" {
28 Ok(dir.join("context_ledger.json"))
29 } else {
30 let ledger_dir = dir.join("ledger");
31 let safe_id: String = agent_id
32 .chars()
33 .map(|c| {
34 if c.is_alphanumeric() || c == '-' || c == '_' {
35 c
36 } else {
37 '_'
38 }
39 })
40 .collect();
41 Ok(ledger_dir.join(format!("{safe_id}.json")))
42 }
43}
44
45fn atomic_write_json(path: &std::path::Path, data: &str) {
46 let _ = crate::config_io::write_atomic(path, data);
47}
48
49#[cfg(unix)]
52fn acquire_ledger_lock(path: &std::path::Path) -> Option<std::fs::File> {
53 use std::os::unix::io::AsRawFd;
54 let lock_path = path.with_extension("json.lock");
55 let file = std::fs::OpenOptions::new()
56 .create(true)
57 .write(true)
58 .truncate(false)
59 .open(&lock_path)
60 .ok()?;
61 let fd = file.as_raw_fd();
62 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
65 if ret != 0 {
66 use std::time::{Duration, Instant};
68 let deadline = Instant::now() + Duration::from_secs(2);
69 loop {
70 std::thread::sleep(Duration::from_millis(50));
71 let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
74 if ret == 0 {
75 break;
76 }
77 if Instant::now() >= deadline {
78 return None;
79 }
80 }
81 }
82 Some(file)
83}
84
85#[cfg(not(unix))]
86fn acquire_ledger_lock(_path: &std::path::Path) -> Option<std::fs::File> {
87 None
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ContextLedger {
92 pub window_size: usize,
93 pub entries: Vec<LedgerEntry>,
94 pub total_tokens_sent: usize,
95 pub total_tokens_saved: usize,
96 #[serde(skip)]
97 last_flush: Option<std::time::Instant>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct LedgerEntry {
102 pub path: String,
103 pub mode: String,
104 pub original_tokens: usize,
105 pub sent_tokens: usize,
106 pub timestamp: i64,
107 #[serde(default)]
108 pub id: Option<ContextItemId>,
109 #[serde(default)]
110 pub kind: Option<ContextKind>,
111 #[serde(default)]
112 pub source_hash: Option<String>,
113 #[serde(default)]
114 pub state: Option<ContextState>,
115 #[serde(default)]
116 pub phi: Option<f64>,
117 #[serde(default)]
118 pub view_costs: Option<ViewCosts>,
119 #[serde(default)]
120 pub active_view: Option<ViewKind>,
121 #[serde(default)]
122 pub provenance: Option<Provenance>,
123 #[serde(default)]
126 pub access_count: u32,
127}
128
129#[derive(Debug, Clone)]
130pub struct ContextPressure {
131 pub utilization: f64,
132 pub remaining_tokens: usize,
133 pub entries_count: usize,
134 pub recommendation: PressureAction,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum PressureAction {
139 NoAction,
140 SuggestCompression,
141 ForceCompression,
142 EvictLeastRelevant,
143}
144
145impl ContextLedger {
146 pub fn new() -> Self {
147 Self {
148 window_size: DEFAULT_CONTEXT_WINDOW,
149 entries: Vec::new(),
150 total_tokens_sent: 0,
151 total_tokens_saved: 0,
152 last_flush: None,
153 }
154 }
155
156 pub fn with_window_size(size: usize) -> Self {
157 Self {
158 window_size: size,
159 entries: Vec::new(),
160 total_tokens_sent: 0,
161 total_tokens_saved: 0,
162 last_flush: None,
163 }
164 }
165
166 pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
167 self.record_with_task(path, mode, original_tokens, sent_tokens, None);
168 }
169
170 pub fn record_with_task(
171 &mut self,
172 path: &str,
173 mode: &str,
174 original_tokens: usize,
175 sent_tokens: usize,
176 task: Option<&str>,
177 ) {
178 let path = crate::core::pathutil::normalize_tool_path(path);
179 let item_id = ContextItemId::from_file(&path);
180
181 let phi =
182 Self::compute_real_phi(&path, sent_tokens, original_tokens, self.window_size, task);
183
184 if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
185 self.total_tokens_sent -= existing.sent_tokens;
186 self.total_tokens_saved -= existing
187 .original_tokens
188 .saturating_sub(existing.sent_tokens);
189 existing.mode = mode.to_string();
190 existing.original_tokens = original_tokens;
191 existing.sent_tokens = sent_tokens;
192 existing.timestamp = chrono::Utc::now().timestamp();
193 existing.access_count = existing.access_count.saturating_add(1);
194 existing.active_view = Some(ViewKind::parse(mode));
195 if existing.id.is_none() {
196 existing.id = Some(item_id);
197 }
198 if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
199 existing.state = Some(ContextState::Included);
200 }
201 existing.phi = Some(match existing.phi {
207 Some(old) => PHI_REREAD_ALPHA * phi + (1.0 - PHI_REREAD_ALPHA) * old,
208 None => phi,
209 });
210 crate::core::introspect::tick("phi_recompute");
211 } else {
212 self.entries.push(LedgerEntry {
213 path: path.clone(),
214 mode: mode.to_string(),
215 original_tokens,
216 sent_tokens,
217 timestamp: chrono::Utc::now().timestamp(),
218 id: Some(item_id),
219 kind: Some(ContextKind::File),
220 source_hash: None,
221 state: Some(ContextState::Included),
222 phi: Some(phi),
223 view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
224 active_view: Some(ViewKind::parse(mode)),
225 provenance: None,
226 access_count: 1,
227 });
228 }
229 self.total_tokens_sent += sent_tokens;
230 self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
231 }
232
233 fn compute_real_phi(
234 path: &str,
235 sent_tokens: usize,
236 original_tokens: usize,
237 window_size: usize,
238 task: Option<&str>,
239 ) -> f64 {
240 use crate::core::context_field::{ContextField, compute_signals_for_path};
241
242 let (signals, _costs) =
243 compute_signals_for_path(path, task, None, window_size, original_tokens);
244 let phi = ContextField::active().compute_phi(&signals);
246 if phi > 0.0 {
247 return phi;
248 }
249
250 Self::compute_lightweight_phi(sent_tokens, window_size)
251 }
252
253 fn compute_lightweight_phi(sent_tokens: usize, window_size: usize) -> f64 {
254 use crate::core::context_field::{ContextField, FieldSignals};
255 let token_cost_norm = if window_size > 0 {
256 (sent_tokens as f64 / window_size as f64).min(1.0)
257 } else {
258 0.0
259 };
260 let signals = FieldSignals {
261 relevance: 1.0,
262 surprise: 0.5,
263 graph_proximity: 0.0,
264 history_signal: 0.0,
265 token_cost_norm,
266 redundancy: 0.0,
267 };
268 ContextField::active().compute_phi(&signals)
269 }
270
271 pub fn upsert(
273 &mut self,
274 path: &str,
275 mode: &str,
276 original_tokens: usize,
277 sent_tokens: usize,
278 source_hash: Option<&str>,
279 kind: ContextKind,
280 provenance: Option<Provenance>,
281 ) {
282 self.record(path, mode, original_tokens, sent_tokens);
283 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
284 entry.kind = Some(kind);
285 if let Some(h) = source_hash
286 && entry.source_hash.as_deref() != Some(h)
287 {
288 if entry.source_hash.is_some() {
289 entry.state = Some(ContextState::Stale);
290 }
291 entry.source_hash = Some(h.to_string());
292 }
293 if let Some(prov) = provenance {
294 entry.provenance = Some(prov);
295 }
296 }
297 }
298
299 pub fn update_phi(&mut self, path: &str, phi: f64) {
301 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
302 entry.phi = Some(phi);
303 }
304 }
305
306 pub fn set_state(&mut self, path: &str, state: ContextState) {
308 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
309 entry.state = Some(state);
310 }
311 }
312
313 pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
315 self.entries.iter().find(|e| e.id.as_ref() == Some(id))
316 }
317
318 pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
320 self.entries
321 .iter()
322 .filter(|e| e.state == Some(state))
323 .collect()
324 }
325
326 pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
329 if self.entries.len() <= keep_count {
330 return Vec::new();
331 }
332 let mut sorted = self.entries.clone();
333 sorted.sort_by(|a, b| {
334 let a_phi = a.phi.unwrap_or(0.0);
335 let b_phi = b.phi.unwrap_or(0.0);
336 a_phi
337 .partial_cmp(&b_phi)
338 .unwrap_or(std::cmp::Ordering::Equal)
339 .then_with(|| a.timestamp.cmp(&b.timestamp))
340 });
341 sorted
342 .iter()
343 .filter(|e| e.state != Some(ContextState::Pinned))
344 .take(self.entries.len() - keep_count)
345 .map(|e| e.path.clone())
346 .collect()
347 }
348
349 pub fn ignite_high_salience(&mut self) -> Vec<String> {
356 let z_threshold = ignition_z_threshold();
357 let phis: Vec<f64> = self.entries.iter().filter_map(|e| e.phi).collect();
358 if phis.len() < GWT_MIN_ENTRIES {
359 return Vec::new();
360 }
361 let n = phis.len() as f64;
362 let mean = phis.iter().sum::<f64>() / n;
363 let var = phis.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n;
364 let std = var.sqrt();
365 if std <= f64::EPSILON {
366 return Vec::new();
367 }
368
369 let mut ignited = Vec::new();
370 for e in &mut self.entries {
371 let Some(phi) = e.phi else { continue };
372 let state = e.state.unwrap_or(ContextState::Included);
373 if matches!(state, ContextState::Excluded | ContextState::Pinned) {
374 continue;
375 }
376 if (phi - mean) / std > z_threshold {
377 e.state = Some(ContextState::Pinned);
378 ignited.push(e.path.clone());
379 }
380 }
381 if !ignited.is_empty() {
382 crate::core::introspect::tick("gwt_ignition");
383 }
384 ignited
385 }
386
387 pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
389 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path)
390 && let Some(ref old_hash) = entry.source_hash
391 && old_hash != new_hash
392 {
393 entry.state = Some(ContextState::Stale);
394 entry.source_hash = Some(new_hash.to_string());
395 }
396 }
397
398 pub fn pressure(&self) -> ContextPressure {
399 let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
400
401 let pinned_count = self
402 .entries
403 .iter()
404 .filter(|e| e.state == Some(ContextState::Pinned))
405 .count();
406 let stale_count = self
407 .entries
408 .iter()
409 .filter(|e| e.state == Some(ContextState::Stale))
410 .count();
411 let pinned_pressure = pinned_count as f64 * 0.02;
412 let stale_penalty = stale_count as f64 * 0.01;
413 let effective_utilization = (utilization + pinned_pressure + stale_penalty).min(1.0);
414
415 let effective_used = (effective_utilization * self.window_size as f64).round() as usize;
416 let remaining = self.window_size.saturating_sub(effective_used);
417
418 let recommendation = if effective_utilization > 0.9 {
419 PressureAction::EvictLeastRelevant
420 } else if effective_utilization > 0.75 {
421 PressureAction::ForceCompression
422 } else if effective_utilization > 0.5 {
423 PressureAction::SuggestCompression
424 } else {
425 PressureAction::NoAction
426 };
427
428 ContextPressure {
429 utilization: effective_utilization,
430 remaining_tokens: remaining,
431 entries_count: self.entries.len(),
432 recommendation,
433 }
434 }
435
436 pub fn compression_ratio(&self) -> f64 {
437 let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
438 if total_original == 0 {
439 return 1.0;
440 }
441 self.total_tokens_sent as f64 / total_original as f64
442 }
443
444 pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
445 let mut costs: Vec<(String, usize)> = self
446 .entries
447 .iter()
448 .map(|e| (e.path.clone(), e.sent_tokens))
449 .collect();
450 costs.sort_by_key(|b| std::cmp::Reverse(b.1));
451 costs
452 }
453
454 pub fn mode_distribution(&self) -> HashMap<String, usize> {
455 let mut dist: HashMap<String, usize> = HashMap::new();
456 for entry in &self.entries {
457 *dist.entry(entry.mode.clone()).or_insert(0) += 1;
458 }
459 dist
460 }
461
462 pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
463 if self.entries.len() <= keep_count {
464 return Vec::new();
465 }
466 let mut sorted = self.entries.clone();
467 sorted.sort_by_key(|e| e.timestamp);
468 sorted
469 .iter()
470 .take(self.entries.len() - keep_count)
471 .map(|e| e.path.clone())
472 .collect()
473 }
474
475 pub fn remove(&mut self, path: &str) -> bool {
476 if let Some(idx) = self.entries.iter().position(|e| e.path == path) {
477 let entry = &self.entries[idx];
478 self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
479 self.total_tokens_saved = self
480 .total_tokens_saved
481 .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
482 self.entries.remove(idx);
483 true
484 } else {
485 false
486 }
487 }
488
489 pub fn reset(&mut self) {
491 let pinned_count = self
492 .entries
493 .iter()
494 .filter(|e| e.state == Some(ContextState::Pinned))
495 .count();
496 self.entries.clear();
497 self.total_tokens_sent = 0;
498 self.total_tokens_saved = 0;
499 if pinned_count > 0 {
500 tracing::info!("{pinned_count} pinned entries were also cleared");
501 }
502 }
503
504 pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
507 let mut removed = 0;
508 for path in paths {
509 let normalized = crate::core::pathutil::normalize_tool_path(path);
510 if self.remove(&normalized) {
511 removed += 1;
512 }
513 }
514 removed
515 }
516
517 pub fn save(&self) {
518 self.save_for_agent("default");
519 }
520
521 pub fn save_debounced(&mut self) {
524 let now = std::time::Instant::now();
525 if let Some(last) = self.last_flush
526 && now.duration_since(last) < std::time::Duration::from_secs(3)
527 {
528 return;
529 }
530 self.save();
531 self.last_flush = Some(now);
532 }
533
534 pub fn save_for_agent(&self, agent_id: &str) {
535 if let Ok(path) = ledger_path(agent_id) {
536 if let Some(parent) = path.parent() {
537 let _ = std::fs::create_dir_all(parent);
538 }
539 let _lock = acquire_ledger_lock(&path);
540 if let Ok(json) = serde_json::to_string(self) {
541 atomic_write_json(&path, &json);
542 }
543 }
544 }
545
546 const MAX_LEDGER_ENTRIES: usize = 200;
547 const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
548
549 pub fn prune(&mut self) -> usize {
550 let before = self.entries.len();
551 let now = chrono::Utc::now().timestamp();
552
553 for entry in &mut self.entries {
554 if let Some(phi) = entry.phi {
555 let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
556 let decayed = phi * 0.95_f64.powf(hours_since);
557 entry.phi = Some(decayed.max(0.0));
558 }
559 }
560
561 self.entries
562 .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
563
564 self.entries.retain(|e| {
565 let age = now - e.timestamp;
566 let phi = e.phi.unwrap_or(0.0);
567 !(age > Self::STALE_AGE_SECS && phi < 0.1)
568 });
569
570 let mut seen = std::collections::HashSet::new();
571 self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
572 self.entries.retain(|e| {
573 let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
579 seen.insert(key)
580 });
581
582 if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
583 self.entries.sort_by(|a, b| {
584 let pa = a.phi.unwrap_or(0.0);
585 let pb = b.phi.unwrap_or(0.0);
586 pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
587 });
588 self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
589 }
590
591 self.rebuild_totals();
592 before - self.entries.len()
593 }
594
595 fn rebuild_totals(&mut self) {
596 self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
597 self.total_tokens_saved = self
598 .entries
599 .iter()
600 .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
601 .sum();
602 }
603
604 pub fn load() -> Self {
605 Self::load_for_agent("default")
606 }
607
608 pub fn load_for_agent(agent_id: &str) -> Self {
609 let mut ledger: Self = ledger_path(agent_id)
610 .ok()
611 .and_then(|p| {
612 let _lock = acquire_ledger_lock(&p);
613 std::fs::read_to_string(p).ok()
614 })
615 .and_then(|s| serde_json::from_str(&s).ok())
616 .unwrap_or_default();
617 if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
618 ledger.window_size = window;
619 }
620 let pruned = ledger.prune();
621 if pruned > 0 {
622 ledger.save_for_agent(agent_id);
623 }
624 ledger
625 }
626
627 pub fn format_summary(&self) -> String {
628 let pressure = self.pressure();
629 format!(
630 "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
631 self.total_tokens_sent,
632 self.window_size,
633 pressure.utilization * 100.0,
634 self.entries.len(),
635 self.compression_ratio(),
636 pressure.recommendation,
637 )
638 }
639
640 pub fn adjusted_total_saved(&self) -> isize {
641 match crate::core::bounce_tracker::global().lock() {
642 Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
643 _ => self.total_tokens_saved as isize,
644 }
645 }
646}
647
648#[derive(Debug, Clone)]
649pub struct ReinjectionAction {
650 pub path: String,
651 pub current_mode: String,
652 pub new_mode: String,
653 pub tokens_freed: usize,
654}
655
656#[derive(Debug, Clone)]
657pub struct ReinjectionPlan {
658 pub actions: Vec<ReinjectionAction>,
659 pub total_tokens_freed: usize,
660 pub new_utilization: f64,
661}
662
663impl ContextLedger {
664 pub fn reinjection_plan(
665 &self,
666 intent: &super::intent_engine::StructuredIntent,
667 target_utilization: f64,
668 ) -> ReinjectionPlan {
669 let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
670 if current_util <= target_utilization {
671 return ReinjectionPlan {
672 actions: Vec::new(),
673 total_tokens_freed: 0,
674 new_utilization: current_util,
675 };
676 }
677
678 let tokens_to_free =
679 self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
680
681 let target_set: std::collections::HashSet<&str> = intent
682 .targets
683 .iter()
684 .map(std::string::String::as_str)
685 .collect();
686
687 let mut candidates: Vec<(usize, &LedgerEntry)> = self
688 .entries
689 .iter()
690 .enumerate()
691 .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
692 .collect();
693
694 candidates.sort_by(|a, b| {
695 let a_phi = a.1.phi.unwrap_or(0.0);
696 let b_phi = b.1.phi.unwrap_or(0.0);
697 a_phi
698 .partial_cmp(&b_phi)
699 .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
700 });
701
702 let mut actions = Vec::new();
703 let mut freed = 0usize;
704
705 for (_, entry) in &candidates {
706 if freed >= tokens_to_free {
707 break;
708 }
709 if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
710 let saving = entry.sent_tokens.saturating_sub(new_tokens);
711 if saving > 0 {
712 actions.push(ReinjectionAction {
713 path: entry.path.clone(),
714 current_mode: entry.mode.clone(),
715 new_mode,
716 tokens_freed: saving,
717 });
718 freed += saving;
719 }
720 }
721 }
722
723 let new_sent = self.total_tokens_sent.saturating_sub(freed);
724 let new_utilization = new_sent as f64 / self.window_size as f64;
725
726 ReinjectionPlan {
727 actions,
728 total_tokens_freed: freed,
729 new_utilization,
730 }
731 }
732}
733
734fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
735 match current_mode {
736 "full" => Some(("signatures".to_string(), current_tokens / 5)),
737 "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
738 "signatures" => Some(("map".to_string(), current_tokens / 2)),
739 "map" => Some(("reference".to_string(), current_tokens / 4)),
740 _ => None,
741 }
742}
743
744fn ignition_z_threshold() -> f64 {
748 std::env::var("LEAN_CTX_GWT_IGNITION_Z")
749 .ok()
750 .and_then(|v| v.trim().parse::<f64>().ok())
751 .filter(|v| *v > 0.0)
752 .unwrap_or(GWT_IGNITION_Z)
753}
754
755impl Default for ContextLedger {
756 fn default() -> Self {
757 Self::new()
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764
765 #[test]
766 fn new_ledger_is_empty() {
767 let ledger = ContextLedger::new();
768 assert_eq!(ledger.total_tokens_sent, 0);
769 assert_eq!(ledger.entries.len(), 0);
770 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
771 }
772
773 #[test]
774 fn record_tracks_tokens() {
775 let mut ledger = ContextLedger::with_window_size(10000);
776 ledger.record("src/main.rs", "full", 500, 500);
777 ledger.record("src/lib.rs", "signatures", 1000, 200);
778 assert_eq!(ledger.total_tokens_sent, 700);
779 assert_eq!(ledger.total_tokens_saved, 800);
780 assert_eq!(ledger.entries.len(), 2);
781 }
782
783 #[test]
784 fn ignition_broadcasts_high_salience_outlier() {
785 let mut ledger = ContextLedger::with_window_size(100_000);
787 for i in 0..5 {
788 ledger.record(&format!("low{i}.rs"), "map", 100, 100);
789 }
790 ledger.record("hot.rs", "full", 100, 100);
791 for e in &mut ledger.entries {
792 e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
793 }
794 let ignited = ledger.ignite_high_salience();
795 assert_eq!(ignited, vec!["hot.rs".to_string()]);
796 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
797 assert_eq!(hot.state, Some(ContextState::Pinned));
798 }
799
800 #[test]
801 fn ignition_skips_small_ledger() {
802 let mut ledger = ContextLedger::with_window_size(100_000);
804 ledger.record("a.rs", "full", 100, 100);
805 ledger.entries[0].phi = Some(0.99);
806 assert!(ledger.ignite_high_salience().is_empty());
807 }
808
809 #[test]
810 fn ignition_is_deterministic() {
811 let build = || {
813 let mut l = ContextLedger::with_window_size(100_000);
814 for i in 0..5 {
815 l.record(&format!("f{i}.rs"), "map", 100, 100);
816 }
817 for (i, e) in l.entries.iter_mut().enumerate() {
818 e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
819 }
820 l
821 };
822 let mut a = build();
823 let mut b = build();
824 assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
825 }
826
827 #[test]
828 fn record_updates_existing_entry() {
829 let mut ledger = ContextLedger::with_window_size(10000);
830 ledger.record("src/main.rs", "full", 500, 500);
831 ledger.record("src/main.rs", "signatures", 500, 100);
832 assert_eq!(ledger.entries.len(), 1);
833 assert_eq!(ledger.total_tokens_sent, 100);
834 assert_eq!(ledger.total_tokens_saved, 400);
835 }
836
837 #[test]
838 fn access_count_tracks_rereads() {
839 let mut ledger = ContextLedger::with_window_size(10000);
840 ledger.record("src/main.rs", "full", 500, 500);
841 assert_eq!(ledger.entries[0].access_count, 1);
842 ledger.record("src/main.rs", "signatures", 500, 100);
843 ledger.record("src/main.rs", "map", 500, 50);
844 assert_eq!(ledger.entries[0].access_count, 3);
845 ledger.record("src/other.rs", "full", 200, 200);
847 let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
848 assert_eq!(other.map(|e| e.access_count), Some(1));
849 }
850
851 #[test]
852 fn pressure_escalates() {
853 let mut ledger = ContextLedger::with_window_size(1000);
854 ledger.record("a.rs", "full", 600, 600);
855 assert_eq!(
856 ledger.pressure().recommendation,
857 PressureAction::SuggestCompression
858 );
859 ledger.record("b.rs", "full", 200, 200);
860 assert_eq!(
861 ledger.pressure().recommendation,
862 PressureAction::ForceCompression
863 );
864 ledger.record("c.rs", "full", 150, 150);
865 assert_eq!(
866 ledger.pressure().recommendation,
867 PressureAction::EvictLeastRelevant
868 );
869 }
870
871 #[test]
872 fn compression_ratio_accurate() {
873 let mut ledger = ContextLedger::with_window_size(10000);
874 ledger.record("a.rs", "full", 1000, 1000);
875 ledger.record("b.rs", "signatures", 1000, 200);
876 let ratio = ledger.compression_ratio();
877 assert!((ratio - 0.6).abs() < 0.01);
878 }
879
880 #[test]
881 fn eviction_returns_oldest() {
882 let mut ledger = ContextLedger::with_window_size(10000);
883 ledger.record("old.rs", "full", 100, 100);
884 std::thread::sleep(std::time::Duration::from_millis(10));
885 ledger.record("new.rs", "full", 100, 100);
886 let candidates = ledger.eviction_candidates(1);
887 assert_eq!(candidates, vec!["old.rs"]);
888 }
889
890 #[test]
891 fn remove_updates_totals() {
892 let mut ledger = ContextLedger::with_window_size(10000);
893 ledger.record("a.rs", "full", 500, 500);
894 ledger.record("b.rs", "full", 300, 300);
895 assert!(ledger.remove("a.rs"));
896 assert_eq!(ledger.total_tokens_sent, 300);
897 assert_eq!(ledger.entries.len(), 1);
898 assert!(!ledger.remove("nonexistent.rs"));
899 }
900
901 #[test]
902 fn reset_clears_everything() {
903 let mut ledger = ContextLedger::with_window_size(10000);
904 ledger.record("a.rs", "full", 500, 500);
905 ledger.record("b.rs", "full", 300, 300);
906 ledger.reset();
907 assert_eq!(ledger.entries.len(), 0);
908 assert_eq!(ledger.total_tokens_sent, 0);
909 assert_eq!(ledger.total_tokens_saved, 0);
910 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
911 }
912
913 #[test]
914 fn evict_paths_removes_matching() {
915 let mut ledger = ContextLedger::with_window_size(10000);
916 ledger.record("a.rs", "full", 500, 500);
917 ledger.record("b.rs", "full", 300, 300);
918 ledger.record("c.rs", "full", 200, 200);
919 let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
920 assert_eq!(removed, 2);
921 assert_eq!(ledger.entries.len(), 1);
922 assert_eq!(ledger.entries[0].path, "b.rs");
923 assert_eq!(ledger.total_tokens_sent, 300);
924 }
925
926 #[test]
927 fn mode_distribution_counts() {
928 let mut ledger = ContextLedger::new();
929 ledger.record("a.rs", "full", 100, 100);
930 ledger.record("b.rs", "signatures", 100, 50);
931 ledger.record("c.rs", "full", 100, 100);
932 let dist = ledger.mode_distribution();
933 assert_eq!(dist.get("full"), Some(&2));
934 assert_eq!(dist.get("signatures"), Some(&1));
935 }
936
937 #[test]
938 fn format_summary_includes_key_info() {
939 let mut ledger = ContextLedger::with_window_size(10000);
940 ledger.record("a.rs", "full", 500, 500);
941 let summary = ledger.format_summary();
942 assert!(summary.contains("500/10000"));
943 assert!(summary.contains("1 files"));
944 }
945
946 #[test]
947 fn reinjection_no_action_when_low_pressure() {
948 use crate::core::intent_engine::StructuredIntent;
949
950 let mut ledger = ContextLedger::with_window_size(10000);
951 ledger.record("a.rs", "full", 100, 100);
952 let intent = StructuredIntent::from_query("fix bug in a.rs");
953 let plan = ledger.reinjection_plan(&intent, 0.7);
954 assert!(plan.actions.is_empty());
955 assert_eq!(plan.total_tokens_freed, 0);
956 }
957
958 #[test]
959 fn reinjection_downgrades_non_target_files() {
960 use crate::core::intent_engine::StructuredIntent;
961
962 let mut ledger = ContextLedger::with_window_size(1000);
963 ledger.record("src/target.rs", "full", 400, 400);
964 std::thread::sleep(std::time::Duration::from_millis(10));
965 ledger.record("src/other.rs", "full", 400, 400);
966 std::thread::sleep(std::time::Duration::from_millis(10));
967 ledger.record("src/utils.rs", "full", 200, 200);
968
969 let intent = StructuredIntent::from_query("fix bug in target.rs");
970 let plan = ledger.reinjection_plan(&intent, 0.5);
971
972 assert!(!plan.actions.is_empty());
973 assert!(
974 plan.actions.iter().all(|a| !a.path.contains("target")),
975 "should not downgrade target file"
976 );
977 assert!(plan.total_tokens_freed > 0);
978 }
979
980 #[test]
981 fn reinjection_preserves_targets() {
982 use crate::core::intent_engine::StructuredIntent;
983
984 let mut ledger = ContextLedger::with_window_size(1000);
985 ledger.record("src/auth.rs", "full", 900, 900);
986 let intent = StructuredIntent::from_query("fix bug in auth.rs");
987 let plan = ledger.reinjection_plan(&intent, 0.5);
988 assert!(
989 plan.actions.is_empty(),
990 "should not downgrade target files even under pressure"
991 );
992 }
993
994 #[test]
995 fn downgrade_mode_chain() {
996 assert_eq!(
997 downgrade_mode("full", 1000),
998 Some(("signatures".to_string(), 200))
999 );
1000 assert_eq!(
1001 downgrade_mode("signatures", 200),
1002 Some(("map".to_string(), 100))
1003 );
1004 assert_eq!(
1005 downgrade_mode("map", 100),
1006 Some(("reference".to_string(), 25))
1007 );
1008 assert_eq!(downgrade_mode("reference", 25), None);
1009 }
1010
1011 #[test]
1012 fn record_assigns_item_id() {
1013 let mut ledger = ContextLedger::new();
1014 ledger.record("src/main.rs", "full", 500, 500);
1015 let entry = &ledger.entries[0];
1016 assert!(entry.id.is_some());
1017 assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1018 }
1019
1020 #[test]
1021 fn record_sets_state_to_included() {
1022 let mut ledger = ContextLedger::new();
1023 ledger.record("src/main.rs", "full", 500, 500);
1024 assert_eq!(
1025 ledger.entries[0].state,
1026 Some(crate::core::context_field::ContextState::Included)
1027 );
1028 }
1029
1030 #[test]
1031 fn record_generates_view_costs() {
1032 let mut ledger = ContextLedger::new();
1033 ledger.record("src/main.rs", "full", 5000, 5000);
1034 let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1035 assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1036 assert_eq!(
1037 vc.get(&crate::core::context_field::ViewKind::Signatures),
1038 1000
1039 );
1040 }
1041
1042 #[test]
1043 fn update_phi_works() {
1044 let mut ledger = ContextLedger::new();
1045 ledger.record("a.rs", "full", 100, 100);
1046 ledger.update_phi("a.rs", 0.85);
1047 assert_eq!(ledger.entries[0].phi, Some(0.85));
1048 }
1049
1050 #[test]
1051 fn set_state_works() {
1052 let mut ledger = ContextLedger::new();
1053 ledger.record("a.rs", "full", 100, 100);
1054 ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1055 assert_eq!(
1056 ledger.entries[0].state,
1057 Some(crate::core::context_field::ContextState::Pinned)
1058 );
1059 }
1060
1061 #[test]
1062 fn items_by_state_filters() {
1063 let mut ledger = ContextLedger::new();
1064 ledger.record("a.rs", "full", 100, 100);
1065 ledger.record("b.rs", "full", 100, 100);
1066 ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1067 let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1068 assert_eq!(included.len(), 1);
1069 assert_eq!(included[0].path, "a.rs");
1070 }
1071
1072 #[test]
1073 fn eviction_by_phi_prefers_low_phi() {
1074 let mut ledger = ContextLedger::with_window_size(10000);
1075 ledger.record("high.rs", "full", 100, 100);
1076 ledger.update_phi("high.rs", 0.9);
1077 ledger.record("low.rs", "full", 100, 100);
1078 ledger.update_phi("low.rs", 0.1);
1079 let candidates = ledger.eviction_candidates_by_phi(1);
1080 assert_eq!(candidates, vec!["low.rs"]);
1081 }
1082
1083 #[test]
1084 fn eviction_by_phi_skips_pinned() {
1085 let mut ledger = ContextLedger::with_window_size(10000);
1086 ledger.record("pinned.rs", "full", 100, 100);
1087 ledger.update_phi("pinned.rs", 0.01);
1088 ledger.set_state(
1089 "pinned.rs",
1090 crate::core::context_field::ContextState::Pinned,
1091 );
1092 ledger.record("normal.rs", "full", 100, 100);
1093 ledger.update_phi("normal.rs", 0.5);
1094 let candidates = ledger.eviction_candidates_by_phi(1);
1095 assert_eq!(candidates, vec!["normal.rs"]);
1096 }
1097
1098 #[test]
1099 fn mark_stale_by_hash_detects_change() {
1100 let mut ledger = ContextLedger::new();
1101 ledger.record("a.rs", "full", 100, 100);
1102 ledger.entries[0].source_hash = Some("hash_v1".to_string());
1103 ledger.mark_stale_by_hash("a.rs", "hash_v2");
1104 assert_eq!(
1105 ledger.entries[0].state,
1106 Some(crate::core::context_field::ContextState::Stale)
1107 );
1108 }
1109
1110 #[test]
1111 fn find_by_id_works() {
1112 let mut ledger = ContextLedger::new();
1113 ledger.record("src/lib.rs", "full", 100, 100);
1114 let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1115 assert!(ledger.find_by_id(&id).is_some());
1116 }
1117
1118 #[test]
1119 fn phi_recomputed_on_reread_not_sticky() {
1120 let _env = crate::core::data_dir::test_env_lock();
1122 let dir = tempfile::tempdir().unwrap();
1123 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1124
1125 let mut ledger = ContextLedger::with_window_size(100_000);
1126 ledger.record_with_task(
1128 "src/authentication.rs",
1129 "full",
1130 2000,
1131 2000,
1132 Some("fix authentication login flow"),
1133 );
1134 let phi_with_task = ledger.entries[0].phi.unwrap();
1135 ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1138 let phi_after = ledger.entries[0].phi.unwrap();
1139 assert_ne!(
1140 phi_with_task, phi_after,
1141 "Phi must be recomputed on re-read (#2)"
1142 );
1143 assert!(
1144 phi_after < phi_with_task,
1145 "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1146 );
1147 }
1148
1149 #[test]
1150 fn upsert_sets_source_hash_and_kind() {
1151 let mut ledger = ContextLedger::new();
1152 ledger.upsert(
1153 "src/main.rs",
1154 "full",
1155 500,
1156 500,
1157 Some("sha256_abc"),
1158 crate::core::context_field::ContextKind::File,
1159 None,
1160 );
1161 let entry = &ledger.entries[0];
1162 assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1163 assert_eq!(
1164 entry.kind,
1165 Some(crate::core::context_field::ContextKind::File)
1166 );
1167 }
1168}