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
145#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum LedgerResolution {
151 Unique(usize),
153 Ambiguous(Vec<String>),
155 NotFound,
156}
157
158#[derive(Debug, Clone)]
162pub struct EvictOutcome {
163 pub target: String,
164 pub resolved: Option<String>,
165 pub ambiguous: Vec<String>,
166}
167
168impl ContextLedger {
169 pub fn new() -> Self {
170 Self {
171 window_size: DEFAULT_CONTEXT_WINDOW,
172 entries: Vec::new(),
173 total_tokens_sent: 0,
174 total_tokens_saved: 0,
175 last_flush: None,
176 }
177 }
178
179 pub fn with_window_size(size: usize) -> Self {
180 Self {
181 window_size: size,
182 entries: Vec::new(),
183 total_tokens_sent: 0,
184 total_tokens_saved: 0,
185 last_flush: None,
186 }
187 }
188
189 pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
190 self.record_with_task(path, mode, original_tokens, sent_tokens, None);
191 }
192
193 pub fn record_with_task(
194 &mut self,
195 path: &str,
196 mode: &str,
197 original_tokens: usize,
198 sent_tokens: usize,
199 task: Option<&str>,
200 ) {
201 let path = crate::core::pathutil::normalize_tool_path(path);
202 let item_id = ContextItemId::from_file(&path);
203
204 let phi =
205 Self::compute_real_phi(&path, sent_tokens, original_tokens, self.window_size, task);
206
207 if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
208 self.total_tokens_sent -= existing.sent_tokens;
209 self.total_tokens_saved -= existing
210 .original_tokens
211 .saturating_sub(existing.sent_tokens);
212 existing.mode = mode.to_string();
213 existing.original_tokens = original_tokens;
214 existing.sent_tokens = sent_tokens;
215 existing.timestamp = chrono::Utc::now().timestamp();
216 existing.access_count = existing.access_count.saturating_add(1);
217 existing.active_view = Some(ViewKind::parse(mode));
218 if existing.id.is_none() {
219 existing.id = Some(item_id);
220 }
221 if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
222 existing.state = Some(ContextState::Included);
223 }
224 existing.phi = Some(match existing.phi {
230 Some(old) => PHI_REREAD_ALPHA * phi + (1.0 - PHI_REREAD_ALPHA) * old,
231 None => phi,
232 });
233 crate::core::introspect::tick("phi_recompute");
234 } else {
235 self.entries.push(LedgerEntry {
236 path: path.clone(),
237 mode: mode.to_string(),
238 original_tokens,
239 sent_tokens,
240 timestamp: chrono::Utc::now().timestamp(),
241 id: Some(item_id),
242 kind: Some(ContextKind::File),
243 source_hash: None,
244 state: Some(ContextState::Included),
245 phi: Some(phi),
246 view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
247 active_view: Some(ViewKind::parse(mode)),
248 provenance: None,
249 access_count: 1,
250 });
251 }
252 self.total_tokens_sent += sent_tokens;
253 self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
254 }
255
256 fn compute_real_phi(
257 path: &str,
258 sent_tokens: usize,
259 original_tokens: usize,
260 window_size: usize,
261 task: Option<&str>,
262 ) -> f64 {
263 use crate::core::context_field::{ContextField, compute_signals_for_path};
264
265 let (signals, _costs) =
266 compute_signals_for_path(path, task, None, window_size, original_tokens);
267 let phi = ContextField::active().compute_phi(&signals);
269 if phi > 0.0 {
270 return phi;
271 }
272
273 Self::compute_lightweight_phi(sent_tokens, window_size)
274 }
275
276 fn compute_lightweight_phi(sent_tokens: usize, window_size: usize) -> f64 {
277 use crate::core::context_field::{ContextField, FieldSignals};
278 let token_cost_norm = if window_size > 0 {
279 (sent_tokens as f64 / window_size as f64).min(1.0)
280 } else {
281 0.0
282 };
283 let signals = FieldSignals {
284 relevance: 1.0,
285 surprise: 0.5,
286 graph_proximity: 0.0,
287 history_signal: 0.0,
288 token_cost_norm,
289 redundancy: 0.0,
290 };
291 ContextField::active().compute_phi(&signals)
292 }
293
294 pub fn upsert(
296 &mut self,
297 path: &str,
298 mode: &str,
299 original_tokens: usize,
300 sent_tokens: usize,
301 source_hash: Option<&str>,
302 kind: ContextKind,
303 provenance: Option<Provenance>,
304 ) {
305 self.record(path, mode, original_tokens, sent_tokens);
306 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
307 entry.kind = Some(kind);
308 if let Some(h) = source_hash
309 && entry.source_hash.as_deref() != Some(h)
310 {
311 if entry.source_hash.is_some() {
312 entry.state = Some(ContextState::Stale);
313 }
314 entry.source_hash = Some(h.to_string());
315 }
316 if let Some(prov) = provenance {
317 entry.provenance = Some(prov);
318 }
319 }
320 }
321
322 pub fn update_phi(&mut self, path: &str, phi: f64) {
324 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
325 entry.phi = Some(phi);
326 }
327 }
328
329 pub fn set_state(&mut self, path: &str, state: ContextState) {
332 if let LedgerResolution::Unique(idx) = self.resolve_entry(path, None) {
333 self.entries[idx].state = Some(state);
334 }
335 }
336
337 pub fn resolve_entry(&self, target: &str, project_root: Option<&str>) -> LedgerResolution {
343 let lex = crate::core::pathutil::normalize_tool_path_lexical(target);
344 if lex.is_empty() {
345 return LedgerResolution::NotFound;
346 }
347
348 if let Some(idx) = self.entries.iter().position(|e| e.path == lex) {
351 return LedgerResolution::Unique(idx);
352 }
353 let full = crate::core::pathutil::normalize_tool_path(target);
354 if full != lex
355 && let Some(idx) = self.entries.iter().position(|e| e.path == full)
356 {
357 return LedgerResolution::Unique(idx);
358 }
359
360 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
362 let joined = format!(
363 "{}/{}",
364 root.trim_end_matches(['/', '\\']),
365 lex.trim_start_matches('/')
366 );
367 let joined_lex = crate::core::pathutil::normalize_tool_path_lexical(&joined);
368 if let Some(idx) = self.entries.iter().position(|e| e.path == joined_lex) {
369 return LedgerResolution::Unique(idx);
370 }
371 let joined_full = crate::core::pathutil::normalize_tool_path(&joined_lex);
372 if joined_full != joined_lex
373 && let Some(idx) = self.entries.iter().position(|e| e.path == joined_full)
374 {
375 return LedgerResolution::Unique(idx);
376 }
377 }
378
379 let suffix = format!("/{}", lex.trim_start_matches('/'));
382 let matches: Vec<usize> = self
383 .entries
384 .iter()
385 .enumerate()
386 .filter(|(_, e)| e.path.ends_with(&suffix))
387 .map(|(idx, _)| idx)
388 .collect();
389 match matches.len() {
390 1 => LedgerResolution::Unique(matches[0]),
391 0 => LedgerResolution::NotFound,
392 _ => LedgerResolution::Ambiguous(
393 matches
394 .iter()
395 .map(|&idx| self.entries[idx].path.clone())
396 .collect(),
397 ),
398 }
399 }
400
401 pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
403 self.entries.iter().find(|e| e.id.as_ref() == Some(id))
404 }
405
406 pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
408 self.entries
409 .iter()
410 .filter(|e| e.state == Some(state))
411 .collect()
412 }
413
414 pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
417 if self.entries.len() <= keep_count {
418 return Vec::new();
419 }
420 let mut sorted = self.entries.clone();
421 sorted.sort_by(|a, b| {
422 let a_phi = a.phi.unwrap_or(0.0);
423 let b_phi = b.phi.unwrap_or(0.0);
424 a_phi
425 .partial_cmp(&b_phi)
426 .unwrap_or(std::cmp::Ordering::Equal)
427 .then_with(|| a.timestamp.cmp(&b.timestamp))
428 });
429 sorted
430 .iter()
431 .filter(|e| e.state != Some(ContextState::Pinned))
432 .take(self.entries.len() - keep_count)
433 .map(|e| e.path.clone())
434 .collect()
435 }
436
437 pub fn ignite_high_salience(&mut self) -> Vec<String> {
444 let z_threshold = ignition_z_threshold();
445 let phis: Vec<f64> = self.entries.iter().filter_map(|e| e.phi).collect();
446 if phis.len() < GWT_MIN_ENTRIES {
447 return Vec::new();
448 }
449 let n = phis.len() as f64;
450 let mean = phis.iter().sum::<f64>() / n;
451 let var = phis.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n;
452 let std = var.sqrt();
453 if std <= f64::EPSILON {
454 return Vec::new();
455 }
456
457 let mut ignited = Vec::new();
458 for e in &mut self.entries {
459 let Some(phi) = e.phi else { continue };
460 let state = e.state.unwrap_or(ContextState::Included);
461 if matches!(state, ContextState::Excluded | ContextState::Pinned) {
462 continue;
463 }
464 if (phi - mean) / std > z_threshold {
465 e.state = Some(ContextState::Pinned);
466 ignited.push(e.path.clone());
467 }
468 }
469 if !ignited.is_empty() {
470 crate::core::introspect::tick("gwt_ignition");
471 }
472 ignited
473 }
474
475 pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
477 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path)
478 && let Some(ref old_hash) = entry.source_hash
479 && old_hash != new_hash
480 {
481 entry.state = Some(ContextState::Stale);
482 entry.source_hash = Some(new_hash.to_string());
483 }
484 }
485
486 pub fn pressure(&self) -> ContextPressure {
487 let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
488
489 let pinned_count = self
490 .entries
491 .iter()
492 .filter(|e| e.state == Some(ContextState::Pinned))
493 .count();
494 let stale_count = self
495 .entries
496 .iter()
497 .filter(|e| e.state == Some(ContextState::Stale))
498 .count();
499 let pinned_pressure = pinned_count as f64 * 0.02;
500 let stale_penalty = stale_count as f64 * 0.01;
501 const MAX_STATE_PRESSURE: f64 = 0.2;
507 let effective_utilization =
508 (utilization + (pinned_pressure + stale_penalty).min(MAX_STATE_PRESSURE)).min(1.0);
509
510 let remaining = self.window_size.saturating_sub(self.total_tokens_sent);
518
519 let recommendation = if effective_utilization > 0.9 {
520 PressureAction::EvictLeastRelevant
521 } else if effective_utilization > 0.75 {
522 PressureAction::ForceCompression
523 } else if effective_utilization > 0.5 {
524 PressureAction::SuggestCompression
525 } else {
526 PressureAction::NoAction
527 };
528
529 ContextPressure {
530 utilization: effective_utilization,
531 remaining_tokens: remaining,
532 entries_count: self.entries.len(),
533 recommendation,
534 }
535 }
536
537 pub fn compression_ratio(&self) -> f64 {
538 let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
539 if total_original == 0 {
540 return 1.0;
541 }
542 self.total_tokens_sent as f64 / total_original as f64
543 }
544
545 pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
546 let mut costs: Vec<(String, usize)> = self
547 .entries
548 .iter()
549 .map(|e| (e.path.clone(), e.sent_tokens))
550 .collect();
551 costs.sort_by_key(|b| std::cmp::Reverse(b.1));
552 costs
553 }
554
555 pub fn mode_distribution(&self) -> HashMap<String, usize> {
556 let mut dist: HashMap<String, usize> = HashMap::new();
557 for entry in &self.entries {
558 *dist.entry(entry.mode.clone()).or_insert(0) += 1;
559 }
560 dist
561 }
562
563 pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
564 if self.entries.len() <= keep_count {
565 return Vec::new();
566 }
567 let mut sorted = self.entries.clone();
568 sorted.sort_by_key(|e| e.timestamp);
569 sorted
570 .iter()
571 .take(self.entries.len() - keep_count)
572 .map(|e| e.path.clone())
573 .collect()
574 }
575
576 pub fn remove(&mut self, path: &str) -> bool {
579 match self.resolve_entry(path, None) {
580 LedgerResolution::Unique(idx) => {
581 self.remove_at(idx);
582 true
583 }
584 _ => false,
585 }
586 }
587
588 fn remove_at(&mut self, idx: usize) {
589 let entry = &self.entries[idx];
590 self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
591 self.total_tokens_saved = self
592 .total_tokens_saved
593 .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
594 self.entries.remove(idx);
595 }
596
597 pub fn reset(&mut self) {
599 let pinned_count = self
600 .entries
601 .iter()
602 .filter(|e| e.state == Some(ContextState::Pinned))
603 .count();
604 self.entries.clear();
605 self.total_tokens_sent = 0;
606 self.total_tokens_saved = 0;
607 if pinned_count > 0 {
608 tracing::info!("{pinned_count} pinned entries were also cleared");
609 }
610 }
611
612 pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
615 self.evict_paths_resolved(paths, None)
616 .iter()
617 .filter(|o| o.resolved.is_some())
618 .count()
619 }
620
621 pub fn evict_paths_resolved(
626 &mut self,
627 paths: &[&str],
628 project_root: Option<&str>,
629 ) -> Vec<EvictOutcome> {
630 paths
631 .iter()
632 .map(|target| match self.resolve_entry(target, project_root) {
633 LedgerResolution::Unique(idx) => {
634 let resolved = self.entries[idx].path.clone();
635 self.remove_at(idx);
636 EvictOutcome {
637 target: (*target).to_string(),
638 resolved: Some(resolved),
639 ambiguous: Vec::new(),
640 }
641 }
642 LedgerResolution::Ambiguous(candidates) => EvictOutcome {
643 target: (*target).to_string(),
644 resolved: None,
645 ambiguous: candidates,
646 },
647 LedgerResolution::NotFound => EvictOutcome {
648 target: (*target).to_string(),
649 resolved: None,
650 ambiguous: Vec::new(),
651 },
652 })
653 .collect()
654 }
655
656 pub fn save(&self) {
657 self.save_for_agent("default");
658 }
659
660 pub fn save_debounced(&mut self) {
663 let now = std::time::Instant::now();
664 if let Some(last) = self.last_flush
665 && now.duration_since(last) < std::time::Duration::from_secs(3)
666 {
667 return;
668 }
669 self.save();
670 self.last_flush = Some(now);
671 }
672
673 pub fn save_for_agent(&self, agent_id: &str) {
674 if let Ok(path) = ledger_path(agent_id) {
675 if let Some(parent) = path.parent() {
676 let _ = std::fs::create_dir_all(parent);
677 }
678 let _lock = acquire_ledger_lock(&path);
679 if let Ok(json) = serde_json::to_string(self) {
680 atomic_write_json(&path, &json);
681 }
682 }
683 }
684
685 const MAX_LEDGER_ENTRIES: usize = 200;
686 const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
687
688 pub fn prune(&mut self) -> usize {
689 let before = self.entries.len();
690 let now = chrono::Utc::now().timestamp();
691
692 for entry in &mut self.entries {
693 if let Some(phi) = entry.phi {
694 let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
695 let decayed = phi * 0.95_f64.powf(hours_since);
696 entry.phi = Some(decayed.max(0.0));
697 }
698 }
699
700 self.entries
701 .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
702
703 self.entries.retain(|e| {
704 let age = now - e.timestamp;
705 let phi = e.phi.unwrap_or(0.0);
706 !(age > Self::STALE_AGE_SECS && phi < 0.1)
707 });
708
709 let mut seen = std::collections::HashSet::new();
710 self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
711 self.entries.retain(|e| {
712 let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
718 seen.insert(key)
719 });
720
721 if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
722 self.entries.sort_by(|a, b| {
723 let pa = a.phi.unwrap_or(0.0);
724 let pb = b.phi.unwrap_or(0.0);
725 pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
726 });
727 self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
728 }
729
730 self.rebuild_totals();
731 before - self.entries.len()
732 }
733
734 fn rebuild_totals(&mut self) {
735 self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
736 self.total_tokens_saved = self
737 .entries
738 .iter()
739 .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
740 .sum();
741 }
742
743 pub fn load() -> Self {
744 Self::load_for_agent("default")
745 }
746
747 pub fn load_for_agent(agent_id: &str) -> Self {
748 let mut ledger: Self = ledger_path(agent_id)
749 .ok()
750 .and_then(|p| {
751 let _lock = acquire_ledger_lock(&p);
752 std::fs::read_to_string(p).ok()
753 })
754 .and_then(|s| serde_json::from_str(&s).ok())
755 .unwrap_or_default();
756 if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
757 ledger.window_size = window;
758 }
759 let mut migrated = false;
763 for entry in &mut ledger.entries {
764 let normalized = crate::core::pathutil::normalize_tool_path_lexical(&entry.path);
765 if normalized != entry.path {
766 entry.path = normalized;
767 migrated = true;
768 }
769 }
770 let pruned = ledger.prune();
771 if pruned > 0 || migrated {
772 ledger.save_for_agent(agent_id);
773 }
774 ledger
775 }
776
777 pub fn format_summary(&self) -> String {
778 let pressure = self.pressure();
779 format!(
780 "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
781 self.total_tokens_sent,
782 self.window_size,
783 pressure.utilization * 100.0,
784 self.entries.len(),
785 self.compression_ratio(),
786 pressure.recommendation,
787 )
788 }
789
790 pub fn adjusted_total_saved(&self) -> isize {
791 match crate::core::bounce_tracker::global().lock() {
792 Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
793 _ => self.total_tokens_saved as isize,
794 }
795 }
796}
797
798#[derive(Debug, Clone)]
799pub struct ReinjectionAction {
800 pub path: String,
801 pub current_mode: String,
802 pub new_mode: String,
803 pub tokens_freed: usize,
804}
805
806#[derive(Debug, Clone)]
807pub struct ReinjectionPlan {
808 pub actions: Vec<ReinjectionAction>,
809 pub total_tokens_freed: usize,
810 pub new_utilization: f64,
811}
812
813impl ContextLedger {
814 pub fn reinjection_plan(
815 &self,
816 intent: &super::intent_engine::StructuredIntent,
817 target_utilization: f64,
818 ) -> ReinjectionPlan {
819 let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
820 if current_util <= target_utilization {
821 return ReinjectionPlan {
822 actions: Vec::new(),
823 total_tokens_freed: 0,
824 new_utilization: current_util,
825 };
826 }
827
828 let tokens_to_free =
829 self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
830
831 let target_set: std::collections::HashSet<&str> = intent
832 .targets
833 .iter()
834 .map(std::string::String::as_str)
835 .collect();
836
837 let mut candidates: Vec<(usize, &LedgerEntry)> = self
838 .entries
839 .iter()
840 .enumerate()
841 .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
842 .collect();
843
844 candidates.sort_by(|a, b| {
845 let a_phi = a.1.phi.unwrap_or(0.0);
846 let b_phi = b.1.phi.unwrap_or(0.0);
847 a_phi
848 .partial_cmp(&b_phi)
849 .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
850 });
851
852 let mut actions = Vec::new();
853 let mut freed = 0usize;
854
855 for (_, entry) in &candidates {
856 if freed >= tokens_to_free {
857 break;
858 }
859 if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
860 let saving = entry.sent_tokens.saturating_sub(new_tokens);
861 if saving > 0 {
862 actions.push(ReinjectionAction {
863 path: entry.path.clone(),
864 current_mode: entry.mode.clone(),
865 new_mode,
866 tokens_freed: saving,
867 });
868 freed += saving;
869 }
870 }
871 }
872
873 let new_sent = self.total_tokens_sent.saturating_sub(freed);
874 let new_utilization = new_sent as f64 / self.window_size as f64;
875
876 ReinjectionPlan {
877 actions,
878 total_tokens_freed: freed,
879 new_utilization,
880 }
881 }
882}
883
884fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
885 match current_mode {
886 "full" => Some(("signatures".to_string(), current_tokens / 5)),
887 "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
888 "signatures" => Some(("map".to_string(), current_tokens / 2)),
889 "map" => Some(("reference".to_string(), current_tokens / 4)),
890 _ => None,
891 }
892}
893
894fn ignition_z_threshold() -> f64 {
898 std::env::var("LEAN_CTX_GWT_IGNITION_Z")
899 .ok()
900 .and_then(|v| v.trim().parse::<f64>().ok())
901 .filter(|v| *v > 0.0)
902 .unwrap_or(GWT_IGNITION_Z)
903}
904
905impl Default for ContextLedger {
906 fn default() -> Self {
907 Self::new()
908 }
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn new_ledger_is_empty() {
917 let ledger = ContextLedger::new();
918 assert_eq!(ledger.total_tokens_sent, 0);
919 assert_eq!(ledger.entries.len(), 0);
920 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
921 }
922
923 #[test]
924 fn record_tracks_tokens() {
925 let mut ledger = ContextLedger::with_window_size(10000);
926 ledger.record("src/main.rs", "full", 500, 500);
927 ledger.record("src/lib.rs", "signatures", 1000, 200);
928 assert_eq!(ledger.total_tokens_sent, 700);
929 assert_eq!(ledger.total_tokens_saved, 800);
930 assert_eq!(ledger.entries.len(), 2);
931 }
932
933 #[test]
934 fn ignition_broadcasts_high_salience_outlier() {
935 let mut ledger = ContextLedger::with_window_size(100_000);
937 for i in 0..5 {
938 ledger.record(&format!("low{i}.rs"), "map", 100, 100);
939 }
940 ledger.record("hot.rs", "full", 100, 100);
941 for e in &mut ledger.entries {
942 e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
943 }
944 let ignited = ledger.ignite_high_salience();
945 assert_eq!(ignited, vec!["hot.rs".to_string()]);
946 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
947 assert_eq!(hot.state, Some(ContextState::Pinned));
948 }
949
950 #[test]
951 fn ignition_skips_small_ledger() {
952 let mut ledger = ContextLedger::with_window_size(100_000);
954 ledger.record("a.rs", "full", 100, 100);
955 ledger.entries[0].phi = Some(0.99);
956 assert!(ledger.ignite_high_salience().is_empty());
957 }
958
959 #[test]
960 fn ignition_is_deterministic() {
961 let build = || {
963 let mut l = ContextLedger::with_window_size(100_000);
964 for i in 0..5 {
965 l.record(&format!("f{i}.rs"), "map", 100, 100);
966 }
967 for (i, e) in l.entries.iter_mut().enumerate() {
968 e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
969 }
970 l
971 };
972 let mut a = build();
973 let mut b = build();
974 assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
975 }
976
977 #[test]
978 fn record_updates_existing_entry() {
979 let mut ledger = ContextLedger::with_window_size(10000);
980 ledger.record("src/main.rs", "full", 500, 500);
981 ledger.record("src/main.rs", "signatures", 500, 100);
982 assert_eq!(ledger.entries.len(), 1);
983 assert_eq!(ledger.total_tokens_sent, 100);
984 assert_eq!(ledger.total_tokens_saved, 400);
985 }
986
987 #[test]
988 fn access_count_tracks_rereads() {
989 let mut ledger = ContextLedger::with_window_size(10000);
990 ledger.record("src/main.rs", "full", 500, 500);
991 assert_eq!(ledger.entries[0].access_count, 1);
992 ledger.record("src/main.rs", "signatures", 500, 100);
993 ledger.record("src/main.rs", "map", 500, 50);
994 assert_eq!(ledger.entries[0].access_count, 3);
995 ledger.record("src/other.rs", "full", 200, 200);
997 let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
998 assert_eq!(other.map(|e| e.access_count), Some(1));
999 }
1000
1001 #[test]
1002 fn pressure_escalates() {
1003 let mut ledger = ContextLedger::with_window_size(1000);
1004 ledger.record("a.rs", "full", 600, 600);
1005 assert_eq!(
1006 ledger.pressure().recommendation,
1007 PressureAction::SuggestCompression
1008 );
1009 ledger.record("b.rs", "full", 200, 200);
1010 assert_eq!(
1011 ledger.pressure().recommendation,
1012 PressureAction::ForceCompression
1013 );
1014 ledger.record("c.rs", "full", 150, 150);
1015 assert_eq!(
1016 ledger.pressure().recommendation,
1017 PressureAction::EvictLeastRelevant
1018 );
1019 }
1020
1021 #[test]
1026 fn pinned_pressure_does_not_zero_out_remaining_tokens() {
1027 let mut ledger = ContextLedger::with_window_size(200_000);
1028 ledger.record("hot.rs", "full", 118_762, 118_762);
1030 for i in 0..32 {
1031 let path = format!("pinned_{i}.rs");
1032 ledger.record(&path, "full", 100, 100);
1033 ledger.set_state(&path, ContextState::Pinned);
1034 }
1035
1036 let pressure = ledger.pressure();
1037 assert!(
1038 pressure.utilization < 1.0,
1039 "32 pinned entries alone must not saturate utilization to 100%, got {}",
1040 pressure.utilization
1041 );
1042 assert!(
1043 pressure.remaining_tokens > 0,
1044 "real token headroom must not be reported as zero"
1045 );
1046 assert_eq!(pressure.remaining_tokens, 200_000 - 121_962);
1048 }
1049
1050 #[test]
1051 fn compression_ratio_accurate() {
1052 let mut ledger = ContextLedger::with_window_size(10000);
1053 ledger.record("a.rs", "full", 1000, 1000);
1054 ledger.record("b.rs", "signatures", 1000, 200);
1055 let ratio = ledger.compression_ratio();
1056 assert!((ratio - 0.6).abs() < 0.01);
1057 }
1058
1059 #[test]
1060 fn eviction_returns_oldest() {
1061 let mut ledger = ContextLedger::with_window_size(10000);
1062 ledger.record("old.rs", "full", 100, 100);
1063 std::thread::sleep(std::time::Duration::from_millis(10));
1064 ledger.record("new.rs", "full", 100, 100);
1065 let candidates = ledger.eviction_candidates(1);
1066 assert_eq!(candidates, vec!["old.rs"]);
1067 }
1068
1069 #[test]
1070 fn remove_updates_totals() {
1071 let mut ledger = ContextLedger::with_window_size(10000);
1072 ledger.record("a.rs", "full", 500, 500);
1073 ledger.record("b.rs", "full", 300, 300);
1074 assert!(ledger.remove("a.rs"));
1075 assert_eq!(ledger.total_tokens_sent, 300);
1076 assert_eq!(ledger.entries.len(), 1);
1077 assert!(!ledger.remove("nonexistent.rs"));
1078 }
1079
1080 #[test]
1081 fn reset_clears_everything() {
1082 let mut ledger = ContextLedger::with_window_size(10000);
1083 ledger.record("a.rs", "full", 500, 500);
1084 ledger.record("b.rs", "full", 300, 300);
1085 ledger.reset();
1086 assert_eq!(ledger.entries.len(), 0);
1087 assert_eq!(ledger.total_tokens_sent, 0);
1088 assert_eq!(ledger.total_tokens_saved, 0);
1089 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
1090 }
1091
1092 #[test]
1093 fn evict_paths_removes_matching() {
1094 let mut ledger = ContextLedger::with_window_size(10000);
1095 ledger.record("a.rs", "full", 500, 500);
1096 ledger.record("b.rs", "full", 300, 300);
1097 ledger.record("c.rs", "full", 200, 200);
1098 let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
1099 assert_eq!(removed, 2);
1100 assert_eq!(ledger.entries.len(), 1);
1101 assert_eq!(ledger.entries[0].path, "b.rs");
1102 assert_eq!(ledger.total_tokens_sent, 300);
1103 }
1104
1105 #[test]
1106 fn mode_distribution_counts() {
1107 let mut ledger = ContextLedger::new();
1108 ledger.record("a.rs", "full", 100, 100);
1109 ledger.record("b.rs", "signatures", 100, 50);
1110 ledger.record("c.rs", "full", 100, 100);
1111 let dist = ledger.mode_distribution();
1112 assert_eq!(dist.get("full"), Some(&2));
1113 assert_eq!(dist.get("signatures"), Some(&1));
1114 }
1115
1116 #[test]
1117 fn format_summary_includes_key_info() {
1118 let mut ledger = ContextLedger::with_window_size(10000);
1119 ledger.record("a.rs", "full", 500, 500);
1120 let summary = ledger.format_summary();
1121 assert!(summary.contains("500/10000"));
1122 assert!(summary.contains("1 files"));
1123 }
1124
1125 #[test]
1126 fn reinjection_no_action_when_low_pressure() {
1127 use crate::core::intent_engine::StructuredIntent;
1128
1129 let mut ledger = ContextLedger::with_window_size(10000);
1130 ledger.record("a.rs", "full", 100, 100);
1131 let intent = StructuredIntent::from_query("fix bug in a.rs");
1132 let plan = ledger.reinjection_plan(&intent, 0.7);
1133 assert!(plan.actions.is_empty());
1134 assert_eq!(plan.total_tokens_freed, 0);
1135 }
1136
1137 #[test]
1138 fn reinjection_downgrades_non_target_files() {
1139 use crate::core::intent_engine::StructuredIntent;
1140
1141 let mut ledger = ContextLedger::with_window_size(1000);
1142 ledger.record("src/target.rs", "full", 400, 400);
1143 std::thread::sleep(std::time::Duration::from_millis(10));
1144 ledger.record("src/other.rs", "full", 400, 400);
1145 std::thread::sleep(std::time::Duration::from_millis(10));
1146 ledger.record("src/utils.rs", "full", 200, 200);
1147
1148 let intent = StructuredIntent::from_query("fix bug in target.rs");
1149 let plan = ledger.reinjection_plan(&intent, 0.5);
1150
1151 assert!(!plan.actions.is_empty());
1152 assert!(
1153 plan.actions.iter().all(|a| !a.path.contains("target")),
1154 "should not downgrade target file"
1155 );
1156 assert!(plan.total_tokens_freed > 0);
1157 }
1158
1159 #[test]
1160 fn reinjection_preserves_targets() {
1161 use crate::core::intent_engine::StructuredIntent;
1162
1163 let mut ledger = ContextLedger::with_window_size(1000);
1164 ledger.record("src/auth.rs", "full", 900, 900);
1165 let intent = StructuredIntent::from_query("fix bug in auth.rs");
1166 let plan = ledger.reinjection_plan(&intent, 0.5);
1167 assert!(
1168 plan.actions.is_empty(),
1169 "should not downgrade target files even under pressure"
1170 );
1171 }
1172
1173 #[test]
1174 fn downgrade_mode_chain() {
1175 assert_eq!(
1176 downgrade_mode("full", 1000),
1177 Some(("signatures".to_string(), 200))
1178 );
1179 assert_eq!(
1180 downgrade_mode("signatures", 200),
1181 Some(("map".to_string(), 100))
1182 );
1183 assert_eq!(
1184 downgrade_mode("map", 100),
1185 Some(("reference".to_string(), 25))
1186 );
1187 assert_eq!(downgrade_mode("reference", 25), None);
1188 }
1189
1190 #[test]
1191 fn record_assigns_item_id() {
1192 let mut ledger = ContextLedger::new();
1193 ledger.record("src/main.rs", "full", 500, 500);
1194 let entry = &ledger.entries[0];
1195 assert!(entry.id.is_some());
1196 assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1197 }
1198
1199 #[test]
1200 fn record_sets_state_to_included() {
1201 let mut ledger = ContextLedger::new();
1202 ledger.record("src/main.rs", "full", 500, 500);
1203 assert_eq!(
1204 ledger.entries[0].state,
1205 Some(crate::core::context_field::ContextState::Included)
1206 );
1207 }
1208
1209 #[test]
1210 fn record_generates_view_costs() {
1211 let mut ledger = ContextLedger::new();
1212 ledger.record("src/main.rs", "full", 5000, 5000);
1213 let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1214 assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1215 assert_eq!(
1216 vc.get(&crate::core::context_field::ViewKind::Signatures),
1217 1000
1218 );
1219 }
1220
1221 #[test]
1222 fn update_phi_works() {
1223 let mut ledger = ContextLedger::new();
1224 ledger.record("a.rs", "full", 100, 100);
1225 ledger.update_phi("a.rs", 0.85);
1226 assert_eq!(ledger.entries[0].phi, Some(0.85));
1227 }
1228
1229 #[test]
1230 fn set_state_works() {
1231 let mut ledger = ContextLedger::new();
1232 ledger.record("a.rs", "full", 100, 100);
1233 ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1234 assert_eq!(
1235 ledger.entries[0].state,
1236 Some(crate::core::context_field::ContextState::Pinned)
1237 );
1238 }
1239
1240 #[test]
1241 fn items_by_state_filters() {
1242 let mut ledger = ContextLedger::new();
1243 ledger.record("a.rs", "full", 100, 100);
1244 ledger.record("b.rs", "full", 100, 100);
1245 ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1246 let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1247 assert_eq!(included.len(), 1);
1248 assert_eq!(included[0].path, "a.rs");
1249 }
1250
1251 #[test]
1252 fn eviction_by_phi_prefers_low_phi() {
1253 let mut ledger = ContextLedger::with_window_size(10000);
1254 ledger.record("high.rs", "full", 100, 100);
1255 ledger.update_phi("high.rs", 0.9);
1256 ledger.record("low.rs", "full", 100, 100);
1257 ledger.update_phi("low.rs", 0.1);
1258 let candidates = ledger.eviction_candidates_by_phi(1);
1259 assert_eq!(candidates, vec!["low.rs"]);
1260 }
1261
1262 #[test]
1263 fn eviction_by_phi_skips_pinned() {
1264 let mut ledger = ContextLedger::with_window_size(10000);
1265 ledger.record("pinned.rs", "full", 100, 100);
1266 ledger.update_phi("pinned.rs", 0.01);
1267 ledger.set_state(
1268 "pinned.rs",
1269 crate::core::context_field::ContextState::Pinned,
1270 );
1271 ledger.record("normal.rs", "full", 100, 100);
1272 ledger.update_phi("normal.rs", 0.5);
1273 let candidates = ledger.eviction_candidates_by_phi(1);
1274 assert_eq!(candidates, vec!["normal.rs"]);
1275 }
1276
1277 #[test]
1278 fn mark_stale_by_hash_detects_change() {
1279 let mut ledger = ContextLedger::new();
1280 ledger.record("a.rs", "full", 100, 100);
1281 ledger.entries[0].source_hash = Some("hash_v1".to_string());
1282 ledger.mark_stale_by_hash("a.rs", "hash_v2");
1283 assert_eq!(
1284 ledger.entries[0].state,
1285 Some(crate::core::context_field::ContextState::Stale)
1286 );
1287 }
1288
1289 #[test]
1290 fn find_by_id_works() {
1291 let mut ledger = ContextLedger::new();
1292 ledger.record("src/lib.rs", "full", 100, 100);
1293 let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1294 assert!(ledger.find_by_id(&id).is_some());
1295 }
1296
1297 #[test]
1298 fn phi_recomputed_on_reread_not_sticky() {
1299 let _env = crate::core::data_dir::test_env_lock();
1301 let dir = tempfile::tempdir().unwrap();
1302 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1303
1304 let mut ledger = ContextLedger::with_window_size(100_000);
1305 ledger.record_with_task(
1307 "src/authentication.rs",
1308 "full",
1309 2000,
1310 2000,
1311 Some("fix authentication login flow"),
1312 );
1313 let phi_with_task = ledger.entries[0].phi.unwrap();
1314 ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1317 let phi_after = ledger.entries[0].phi.unwrap();
1318 assert_ne!(
1319 phi_with_task, phi_after,
1320 "Phi must be recomputed on re-read (#2)"
1321 );
1322 assert!(
1323 phi_after < phi_with_task,
1324 "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1325 );
1326 }
1327
1328 #[test]
1331 fn resolve_entry_matches_basename_suffix_uniquely() {
1332 let mut ledger = ContextLedger::with_window_size(10000);
1333 ledger.record("/home/user/proj/src/context_ledger.rs", "full", 500, 500);
1334 ledger.record("/home/user/proj/src/other.rs", "full", 300, 300);
1335
1336 assert_eq!(
1338 ledger.resolve_entry("context_ledger.rs", None),
1339 LedgerResolution::Unique(0)
1340 );
1341 assert_eq!(
1343 ledger.resolve_entry("src/other.rs", None),
1344 LedgerResolution::Unique(1)
1345 );
1346 assert_eq!(
1348 ledger.resolve_entry("edger.rs", None),
1349 LedgerResolution::NotFound
1350 );
1351 }
1352
1353 #[test]
1354 fn resolve_entry_reports_ambiguous_suffix() {
1355 let mut ledger = ContextLedger::with_window_size(10000);
1356 ledger.record("/proj/a/mod.rs", "full", 100, 100);
1357 ledger.record("/proj/b/mod.rs", "full", 100, 100);
1358
1359 match ledger.resolve_entry("mod.rs", None) {
1360 LedgerResolution::Ambiguous(candidates) => {
1361 assert_eq!(candidates.len(), 2);
1362 assert!(candidates.contains(&"/proj/a/mod.rs".to_string()));
1363 }
1364 other => panic!("expected Ambiguous, got {other:?}"),
1365 }
1366 assert_eq!(
1368 ledger.resolve_entry("a/mod.rs", None),
1369 LedgerResolution::Unique(0)
1370 );
1371 }
1372
1373 #[test]
1374 fn resolve_entry_prefers_project_root_relative() {
1375 let mut ledger = ContextLedger::with_window_size(10000);
1376 ledger.record("/work/proj/src/lib.rs", "full", 100, 100);
1377 ledger.record("/elsewhere/src/lib.rs", "full", 100, 100);
1378
1379 assert!(matches!(
1381 ledger.resolve_entry("src/lib.rs", None),
1382 LedgerResolution::Ambiguous(_)
1383 ));
1384 assert_eq!(
1385 ledger.resolve_entry("src/lib.rs", Some("/work/proj")),
1386 LedgerResolution::Unique(0)
1387 );
1388 }
1389
1390 #[test]
1391 fn resolve_entry_handles_windows_separators() {
1392 let mut ledger = ContextLedger::with_window_size(10000);
1393 ledger.entries.push(LedgerEntry {
1395 path: "C:/Users/dev/proj/src/main.rs".to_string(),
1396 mode: "full".to_string(),
1397 original_tokens: 100,
1398 sent_tokens: 100,
1399 timestamp: chrono::Utc::now().timestamp(),
1400 id: None,
1401 kind: None,
1402 source_hash: None,
1403 state: None,
1404 phi: None,
1405 view_costs: None,
1406 active_view: None,
1407 provenance: None,
1408 access_count: 1,
1409 });
1410 ledger.total_tokens_sent = 100;
1411
1412 assert_eq!(
1414 ledger.resolve_entry("src\\main.rs", None),
1415 LedgerResolution::Unique(0)
1416 );
1417 assert_eq!(ledger.evict_paths(&["src\\main.rs"]), 1);
1418 assert!(ledger.entries.is_empty());
1419 }
1420
1421 #[test]
1422 fn evict_paths_resolved_reports_outcomes() {
1423 let mut ledger = ContextLedger::with_window_size(10000);
1424 ledger.record("/proj/src/gate.rs", "full", 500, 500);
1425 ledger.record("/proj/a/dup.rs", "full", 100, 100);
1426 ledger.record("/proj/b/dup.rs", "full", 100, 100);
1427
1428 let outcomes =
1429 ledger.evict_paths_resolved(&["gate.rs", "dup.rs", "missing.rs"], Some("/proj"));
1430 assert_eq!(
1431 outcomes[0].resolved.as_deref(),
1432 Some("/proj/src/gate.rs"),
1433 "basename must resolve and evict"
1434 );
1435 assert!(outcomes[1].resolved.is_none());
1436 assert_eq!(outcomes[1].ambiguous.len(), 2, "ambiguity is diagnosed");
1437 assert!(outcomes[2].resolved.is_none());
1438 assert!(outcomes[2].ambiguous.is_empty());
1439 assert_eq!(ledger.entries.len(), 2, "only the unique match is removed");
1440 assert_eq!(ledger.total_tokens_sent, 200);
1441 }
1442
1443 #[test]
1444 fn set_state_resolves_partial_paths() {
1445 let mut ledger = ContextLedger::new();
1446 ledger.record("/proj/src/deep/file.rs", "full", 100, 100);
1447 ledger.set_state("file.rs", crate::core::context_field::ContextState::Pinned);
1448 assert_eq!(
1449 ledger.entries[0].state,
1450 Some(crate::core::context_field::ContextState::Pinned)
1451 );
1452 }
1453
1454 #[test]
1455 fn upsert_sets_source_hash_and_kind() {
1456 let mut ledger = ContextLedger::new();
1457 ledger.upsert(
1458 "src/main.rs",
1459 "full",
1460 500,
1461 500,
1462 Some("sha256_abc"),
1463 crate::core::context_field::ContextKind::File,
1464 None,
1465 );
1466 let entry = &ledger.entries[0];
1467 assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1468 assert_eq!(
1469 entry.kind,
1470 Some(crate::core::context_field::ContextKind::File)
1471 );
1472 }
1473}