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 let effective_utilization = (utilization + pinned_pressure + stale_penalty).min(1.0);
502
503 let effective_used = (effective_utilization * self.window_size as f64).round() as usize;
504 let remaining = self.window_size.saturating_sub(effective_used);
505
506 let recommendation = if effective_utilization > 0.9 {
507 PressureAction::EvictLeastRelevant
508 } else if effective_utilization > 0.75 {
509 PressureAction::ForceCompression
510 } else if effective_utilization > 0.5 {
511 PressureAction::SuggestCompression
512 } else {
513 PressureAction::NoAction
514 };
515
516 ContextPressure {
517 utilization: effective_utilization,
518 remaining_tokens: remaining,
519 entries_count: self.entries.len(),
520 recommendation,
521 }
522 }
523
524 pub fn compression_ratio(&self) -> f64 {
525 let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
526 if total_original == 0 {
527 return 1.0;
528 }
529 self.total_tokens_sent as f64 / total_original as f64
530 }
531
532 pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
533 let mut costs: Vec<(String, usize)> = self
534 .entries
535 .iter()
536 .map(|e| (e.path.clone(), e.sent_tokens))
537 .collect();
538 costs.sort_by_key(|b| std::cmp::Reverse(b.1));
539 costs
540 }
541
542 pub fn mode_distribution(&self) -> HashMap<String, usize> {
543 let mut dist: HashMap<String, usize> = HashMap::new();
544 for entry in &self.entries {
545 *dist.entry(entry.mode.clone()).or_insert(0) += 1;
546 }
547 dist
548 }
549
550 pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
551 if self.entries.len() <= keep_count {
552 return Vec::new();
553 }
554 let mut sorted = self.entries.clone();
555 sorted.sort_by_key(|e| e.timestamp);
556 sorted
557 .iter()
558 .take(self.entries.len() - keep_count)
559 .map(|e| e.path.clone())
560 .collect()
561 }
562
563 pub fn remove(&mut self, path: &str) -> bool {
566 match self.resolve_entry(path, None) {
567 LedgerResolution::Unique(idx) => {
568 self.remove_at(idx);
569 true
570 }
571 _ => false,
572 }
573 }
574
575 fn remove_at(&mut self, idx: usize) {
576 let entry = &self.entries[idx];
577 self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
578 self.total_tokens_saved = self
579 .total_tokens_saved
580 .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
581 self.entries.remove(idx);
582 }
583
584 pub fn reset(&mut self) {
586 let pinned_count = self
587 .entries
588 .iter()
589 .filter(|e| e.state == Some(ContextState::Pinned))
590 .count();
591 self.entries.clear();
592 self.total_tokens_sent = 0;
593 self.total_tokens_saved = 0;
594 if pinned_count > 0 {
595 tracing::info!("{pinned_count} pinned entries were also cleared");
596 }
597 }
598
599 pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
602 self.evict_paths_resolved(paths, None)
603 .iter()
604 .filter(|o| o.resolved.is_some())
605 .count()
606 }
607
608 pub fn evict_paths_resolved(
613 &mut self,
614 paths: &[&str],
615 project_root: Option<&str>,
616 ) -> Vec<EvictOutcome> {
617 paths
618 .iter()
619 .map(|target| match self.resolve_entry(target, project_root) {
620 LedgerResolution::Unique(idx) => {
621 let resolved = self.entries[idx].path.clone();
622 self.remove_at(idx);
623 EvictOutcome {
624 target: (*target).to_string(),
625 resolved: Some(resolved),
626 ambiguous: Vec::new(),
627 }
628 }
629 LedgerResolution::Ambiguous(candidates) => EvictOutcome {
630 target: (*target).to_string(),
631 resolved: None,
632 ambiguous: candidates,
633 },
634 LedgerResolution::NotFound => EvictOutcome {
635 target: (*target).to_string(),
636 resolved: None,
637 ambiguous: Vec::new(),
638 },
639 })
640 .collect()
641 }
642
643 pub fn save(&self) {
644 self.save_for_agent("default");
645 }
646
647 pub fn save_debounced(&mut self) {
650 let now = std::time::Instant::now();
651 if let Some(last) = self.last_flush
652 && now.duration_since(last) < std::time::Duration::from_secs(3)
653 {
654 return;
655 }
656 self.save();
657 self.last_flush = Some(now);
658 }
659
660 pub fn save_for_agent(&self, agent_id: &str) {
661 if let Ok(path) = ledger_path(agent_id) {
662 if let Some(parent) = path.parent() {
663 let _ = std::fs::create_dir_all(parent);
664 }
665 let _lock = acquire_ledger_lock(&path);
666 if let Ok(json) = serde_json::to_string(self) {
667 atomic_write_json(&path, &json);
668 }
669 }
670 }
671
672 const MAX_LEDGER_ENTRIES: usize = 200;
673 const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
674
675 pub fn prune(&mut self) -> usize {
676 let before = self.entries.len();
677 let now = chrono::Utc::now().timestamp();
678
679 for entry in &mut self.entries {
680 if let Some(phi) = entry.phi {
681 let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
682 let decayed = phi * 0.95_f64.powf(hours_since);
683 entry.phi = Some(decayed.max(0.0));
684 }
685 }
686
687 self.entries
688 .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
689
690 self.entries.retain(|e| {
691 let age = now - e.timestamp;
692 let phi = e.phi.unwrap_or(0.0);
693 !(age > Self::STALE_AGE_SECS && phi < 0.1)
694 });
695
696 let mut seen = std::collections::HashSet::new();
697 self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
698 self.entries.retain(|e| {
699 let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
705 seen.insert(key)
706 });
707
708 if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
709 self.entries.sort_by(|a, b| {
710 let pa = a.phi.unwrap_or(0.0);
711 let pb = b.phi.unwrap_or(0.0);
712 pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
713 });
714 self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
715 }
716
717 self.rebuild_totals();
718 before - self.entries.len()
719 }
720
721 fn rebuild_totals(&mut self) {
722 self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
723 self.total_tokens_saved = self
724 .entries
725 .iter()
726 .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
727 .sum();
728 }
729
730 pub fn load() -> Self {
731 Self::load_for_agent("default")
732 }
733
734 pub fn load_for_agent(agent_id: &str) -> Self {
735 let mut ledger: Self = ledger_path(agent_id)
736 .ok()
737 .and_then(|p| {
738 let _lock = acquire_ledger_lock(&p);
739 std::fs::read_to_string(p).ok()
740 })
741 .and_then(|s| serde_json::from_str(&s).ok())
742 .unwrap_or_default();
743 if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
744 ledger.window_size = window;
745 }
746 let mut migrated = false;
750 for entry in &mut ledger.entries {
751 let normalized = crate::core::pathutil::normalize_tool_path_lexical(&entry.path);
752 if normalized != entry.path {
753 entry.path = normalized;
754 migrated = true;
755 }
756 }
757 let pruned = ledger.prune();
758 if pruned > 0 || migrated {
759 ledger.save_for_agent(agent_id);
760 }
761 ledger
762 }
763
764 pub fn format_summary(&self) -> String {
765 let pressure = self.pressure();
766 format!(
767 "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
768 self.total_tokens_sent,
769 self.window_size,
770 pressure.utilization * 100.0,
771 self.entries.len(),
772 self.compression_ratio(),
773 pressure.recommendation,
774 )
775 }
776
777 pub fn adjusted_total_saved(&self) -> isize {
778 match crate::core::bounce_tracker::global().lock() {
779 Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
780 _ => self.total_tokens_saved as isize,
781 }
782 }
783}
784
785#[derive(Debug, Clone)]
786pub struct ReinjectionAction {
787 pub path: String,
788 pub current_mode: String,
789 pub new_mode: String,
790 pub tokens_freed: usize,
791}
792
793#[derive(Debug, Clone)]
794pub struct ReinjectionPlan {
795 pub actions: Vec<ReinjectionAction>,
796 pub total_tokens_freed: usize,
797 pub new_utilization: f64,
798}
799
800impl ContextLedger {
801 pub fn reinjection_plan(
802 &self,
803 intent: &super::intent_engine::StructuredIntent,
804 target_utilization: f64,
805 ) -> ReinjectionPlan {
806 let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
807 if current_util <= target_utilization {
808 return ReinjectionPlan {
809 actions: Vec::new(),
810 total_tokens_freed: 0,
811 new_utilization: current_util,
812 };
813 }
814
815 let tokens_to_free =
816 self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
817
818 let target_set: std::collections::HashSet<&str> = intent
819 .targets
820 .iter()
821 .map(std::string::String::as_str)
822 .collect();
823
824 let mut candidates: Vec<(usize, &LedgerEntry)> = self
825 .entries
826 .iter()
827 .enumerate()
828 .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
829 .collect();
830
831 candidates.sort_by(|a, b| {
832 let a_phi = a.1.phi.unwrap_or(0.0);
833 let b_phi = b.1.phi.unwrap_or(0.0);
834 a_phi
835 .partial_cmp(&b_phi)
836 .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
837 });
838
839 let mut actions = Vec::new();
840 let mut freed = 0usize;
841
842 for (_, entry) in &candidates {
843 if freed >= tokens_to_free {
844 break;
845 }
846 if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
847 let saving = entry.sent_tokens.saturating_sub(new_tokens);
848 if saving > 0 {
849 actions.push(ReinjectionAction {
850 path: entry.path.clone(),
851 current_mode: entry.mode.clone(),
852 new_mode,
853 tokens_freed: saving,
854 });
855 freed += saving;
856 }
857 }
858 }
859
860 let new_sent = self.total_tokens_sent.saturating_sub(freed);
861 let new_utilization = new_sent as f64 / self.window_size as f64;
862
863 ReinjectionPlan {
864 actions,
865 total_tokens_freed: freed,
866 new_utilization,
867 }
868 }
869}
870
871fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
872 match current_mode {
873 "full" => Some(("signatures".to_string(), current_tokens / 5)),
874 "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
875 "signatures" => Some(("map".to_string(), current_tokens / 2)),
876 "map" => Some(("reference".to_string(), current_tokens / 4)),
877 _ => None,
878 }
879}
880
881fn ignition_z_threshold() -> f64 {
885 std::env::var("LEAN_CTX_GWT_IGNITION_Z")
886 .ok()
887 .and_then(|v| v.trim().parse::<f64>().ok())
888 .filter(|v| *v > 0.0)
889 .unwrap_or(GWT_IGNITION_Z)
890}
891
892impl Default for ContextLedger {
893 fn default() -> Self {
894 Self::new()
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 #[test]
903 fn new_ledger_is_empty() {
904 let ledger = ContextLedger::new();
905 assert_eq!(ledger.total_tokens_sent, 0);
906 assert_eq!(ledger.entries.len(), 0);
907 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
908 }
909
910 #[test]
911 fn record_tracks_tokens() {
912 let mut ledger = ContextLedger::with_window_size(10000);
913 ledger.record("src/main.rs", "full", 500, 500);
914 ledger.record("src/lib.rs", "signatures", 1000, 200);
915 assert_eq!(ledger.total_tokens_sent, 700);
916 assert_eq!(ledger.total_tokens_saved, 800);
917 assert_eq!(ledger.entries.len(), 2);
918 }
919
920 #[test]
921 fn ignition_broadcasts_high_salience_outlier() {
922 let mut ledger = ContextLedger::with_window_size(100_000);
924 for i in 0..5 {
925 ledger.record(&format!("low{i}.rs"), "map", 100, 100);
926 }
927 ledger.record("hot.rs", "full", 100, 100);
928 for e in &mut ledger.entries {
929 e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
930 }
931 let ignited = ledger.ignite_high_salience();
932 assert_eq!(ignited, vec!["hot.rs".to_string()]);
933 let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
934 assert_eq!(hot.state, Some(ContextState::Pinned));
935 }
936
937 #[test]
938 fn ignition_skips_small_ledger() {
939 let mut ledger = ContextLedger::with_window_size(100_000);
941 ledger.record("a.rs", "full", 100, 100);
942 ledger.entries[0].phi = Some(0.99);
943 assert!(ledger.ignite_high_salience().is_empty());
944 }
945
946 #[test]
947 fn ignition_is_deterministic() {
948 let build = || {
950 let mut l = ContextLedger::with_window_size(100_000);
951 for i in 0..5 {
952 l.record(&format!("f{i}.rs"), "map", 100, 100);
953 }
954 for (i, e) in l.entries.iter_mut().enumerate() {
955 e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
956 }
957 l
958 };
959 let mut a = build();
960 let mut b = build();
961 assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
962 }
963
964 #[test]
965 fn record_updates_existing_entry() {
966 let mut ledger = ContextLedger::with_window_size(10000);
967 ledger.record("src/main.rs", "full", 500, 500);
968 ledger.record("src/main.rs", "signatures", 500, 100);
969 assert_eq!(ledger.entries.len(), 1);
970 assert_eq!(ledger.total_tokens_sent, 100);
971 assert_eq!(ledger.total_tokens_saved, 400);
972 }
973
974 #[test]
975 fn access_count_tracks_rereads() {
976 let mut ledger = ContextLedger::with_window_size(10000);
977 ledger.record("src/main.rs", "full", 500, 500);
978 assert_eq!(ledger.entries[0].access_count, 1);
979 ledger.record("src/main.rs", "signatures", 500, 100);
980 ledger.record("src/main.rs", "map", 500, 50);
981 assert_eq!(ledger.entries[0].access_count, 3);
982 ledger.record("src/other.rs", "full", 200, 200);
984 let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
985 assert_eq!(other.map(|e| e.access_count), Some(1));
986 }
987
988 #[test]
989 fn pressure_escalates() {
990 let mut ledger = ContextLedger::with_window_size(1000);
991 ledger.record("a.rs", "full", 600, 600);
992 assert_eq!(
993 ledger.pressure().recommendation,
994 PressureAction::SuggestCompression
995 );
996 ledger.record("b.rs", "full", 200, 200);
997 assert_eq!(
998 ledger.pressure().recommendation,
999 PressureAction::ForceCompression
1000 );
1001 ledger.record("c.rs", "full", 150, 150);
1002 assert_eq!(
1003 ledger.pressure().recommendation,
1004 PressureAction::EvictLeastRelevant
1005 );
1006 }
1007
1008 #[test]
1009 fn compression_ratio_accurate() {
1010 let mut ledger = ContextLedger::with_window_size(10000);
1011 ledger.record("a.rs", "full", 1000, 1000);
1012 ledger.record("b.rs", "signatures", 1000, 200);
1013 let ratio = ledger.compression_ratio();
1014 assert!((ratio - 0.6).abs() < 0.01);
1015 }
1016
1017 #[test]
1018 fn eviction_returns_oldest() {
1019 let mut ledger = ContextLedger::with_window_size(10000);
1020 ledger.record("old.rs", "full", 100, 100);
1021 std::thread::sleep(std::time::Duration::from_millis(10));
1022 ledger.record("new.rs", "full", 100, 100);
1023 let candidates = ledger.eviction_candidates(1);
1024 assert_eq!(candidates, vec!["old.rs"]);
1025 }
1026
1027 #[test]
1028 fn remove_updates_totals() {
1029 let mut ledger = ContextLedger::with_window_size(10000);
1030 ledger.record("a.rs", "full", 500, 500);
1031 ledger.record("b.rs", "full", 300, 300);
1032 assert!(ledger.remove("a.rs"));
1033 assert_eq!(ledger.total_tokens_sent, 300);
1034 assert_eq!(ledger.entries.len(), 1);
1035 assert!(!ledger.remove("nonexistent.rs"));
1036 }
1037
1038 #[test]
1039 fn reset_clears_everything() {
1040 let mut ledger = ContextLedger::with_window_size(10000);
1041 ledger.record("a.rs", "full", 500, 500);
1042 ledger.record("b.rs", "full", 300, 300);
1043 ledger.reset();
1044 assert_eq!(ledger.entries.len(), 0);
1045 assert_eq!(ledger.total_tokens_sent, 0);
1046 assert_eq!(ledger.total_tokens_saved, 0);
1047 assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
1048 }
1049
1050 #[test]
1051 fn evict_paths_removes_matching() {
1052 let mut ledger = ContextLedger::with_window_size(10000);
1053 ledger.record("a.rs", "full", 500, 500);
1054 ledger.record("b.rs", "full", 300, 300);
1055 ledger.record("c.rs", "full", 200, 200);
1056 let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
1057 assert_eq!(removed, 2);
1058 assert_eq!(ledger.entries.len(), 1);
1059 assert_eq!(ledger.entries[0].path, "b.rs");
1060 assert_eq!(ledger.total_tokens_sent, 300);
1061 }
1062
1063 #[test]
1064 fn mode_distribution_counts() {
1065 let mut ledger = ContextLedger::new();
1066 ledger.record("a.rs", "full", 100, 100);
1067 ledger.record("b.rs", "signatures", 100, 50);
1068 ledger.record("c.rs", "full", 100, 100);
1069 let dist = ledger.mode_distribution();
1070 assert_eq!(dist.get("full"), Some(&2));
1071 assert_eq!(dist.get("signatures"), Some(&1));
1072 }
1073
1074 #[test]
1075 fn format_summary_includes_key_info() {
1076 let mut ledger = ContextLedger::with_window_size(10000);
1077 ledger.record("a.rs", "full", 500, 500);
1078 let summary = ledger.format_summary();
1079 assert!(summary.contains("500/10000"));
1080 assert!(summary.contains("1 files"));
1081 }
1082
1083 #[test]
1084 fn reinjection_no_action_when_low_pressure() {
1085 use crate::core::intent_engine::StructuredIntent;
1086
1087 let mut ledger = ContextLedger::with_window_size(10000);
1088 ledger.record("a.rs", "full", 100, 100);
1089 let intent = StructuredIntent::from_query("fix bug in a.rs");
1090 let plan = ledger.reinjection_plan(&intent, 0.7);
1091 assert!(plan.actions.is_empty());
1092 assert_eq!(plan.total_tokens_freed, 0);
1093 }
1094
1095 #[test]
1096 fn reinjection_downgrades_non_target_files() {
1097 use crate::core::intent_engine::StructuredIntent;
1098
1099 let mut ledger = ContextLedger::with_window_size(1000);
1100 ledger.record("src/target.rs", "full", 400, 400);
1101 std::thread::sleep(std::time::Duration::from_millis(10));
1102 ledger.record("src/other.rs", "full", 400, 400);
1103 std::thread::sleep(std::time::Duration::from_millis(10));
1104 ledger.record("src/utils.rs", "full", 200, 200);
1105
1106 let intent = StructuredIntent::from_query("fix bug in target.rs");
1107 let plan = ledger.reinjection_plan(&intent, 0.5);
1108
1109 assert!(!plan.actions.is_empty());
1110 assert!(
1111 plan.actions.iter().all(|a| !a.path.contains("target")),
1112 "should not downgrade target file"
1113 );
1114 assert!(plan.total_tokens_freed > 0);
1115 }
1116
1117 #[test]
1118 fn reinjection_preserves_targets() {
1119 use crate::core::intent_engine::StructuredIntent;
1120
1121 let mut ledger = ContextLedger::with_window_size(1000);
1122 ledger.record("src/auth.rs", "full", 900, 900);
1123 let intent = StructuredIntent::from_query("fix bug in auth.rs");
1124 let plan = ledger.reinjection_plan(&intent, 0.5);
1125 assert!(
1126 plan.actions.is_empty(),
1127 "should not downgrade target files even under pressure"
1128 );
1129 }
1130
1131 #[test]
1132 fn downgrade_mode_chain() {
1133 assert_eq!(
1134 downgrade_mode("full", 1000),
1135 Some(("signatures".to_string(), 200))
1136 );
1137 assert_eq!(
1138 downgrade_mode("signatures", 200),
1139 Some(("map".to_string(), 100))
1140 );
1141 assert_eq!(
1142 downgrade_mode("map", 100),
1143 Some(("reference".to_string(), 25))
1144 );
1145 assert_eq!(downgrade_mode("reference", 25), None);
1146 }
1147
1148 #[test]
1149 fn record_assigns_item_id() {
1150 let mut ledger = ContextLedger::new();
1151 ledger.record("src/main.rs", "full", 500, 500);
1152 let entry = &ledger.entries[0];
1153 assert!(entry.id.is_some());
1154 assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1155 }
1156
1157 #[test]
1158 fn record_sets_state_to_included() {
1159 let mut ledger = ContextLedger::new();
1160 ledger.record("src/main.rs", "full", 500, 500);
1161 assert_eq!(
1162 ledger.entries[0].state,
1163 Some(crate::core::context_field::ContextState::Included)
1164 );
1165 }
1166
1167 #[test]
1168 fn record_generates_view_costs() {
1169 let mut ledger = ContextLedger::new();
1170 ledger.record("src/main.rs", "full", 5000, 5000);
1171 let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1172 assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1173 assert_eq!(
1174 vc.get(&crate::core::context_field::ViewKind::Signatures),
1175 1000
1176 );
1177 }
1178
1179 #[test]
1180 fn update_phi_works() {
1181 let mut ledger = ContextLedger::new();
1182 ledger.record("a.rs", "full", 100, 100);
1183 ledger.update_phi("a.rs", 0.85);
1184 assert_eq!(ledger.entries[0].phi, Some(0.85));
1185 }
1186
1187 #[test]
1188 fn set_state_works() {
1189 let mut ledger = ContextLedger::new();
1190 ledger.record("a.rs", "full", 100, 100);
1191 ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1192 assert_eq!(
1193 ledger.entries[0].state,
1194 Some(crate::core::context_field::ContextState::Pinned)
1195 );
1196 }
1197
1198 #[test]
1199 fn items_by_state_filters() {
1200 let mut ledger = ContextLedger::new();
1201 ledger.record("a.rs", "full", 100, 100);
1202 ledger.record("b.rs", "full", 100, 100);
1203 ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1204 let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1205 assert_eq!(included.len(), 1);
1206 assert_eq!(included[0].path, "a.rs");
1207 }
1208
1209 #[test]
1210 fn eviction_by_phi_prefers_low_phi() {
1211 let mut ledger = ContextLedger::with_window_size(10000);
1212 ledger.record("high.rs", "full", 100, 100);
1213 ledger.update_phi("high.rs", 0.9);
1214 ledger.record("low.rs", "full", 100, 100);
1215 ledger.update_phi("low.rs", 0.1);
1216 let candidates = ledger.eviction_candidates_by_phi(1);
1217 assert_eq!(candidates, vec!["low.rs"]);
1218 }
1219
1220 #[test]
1221 fn eviction_by_phi_skips_pinned() {
1222 let mut ledger = ContextLedger::with_window_size(10000);
1223 ledger.record("pinned.rs", "full", 100, 100);
1224 ledger.update_phi("pinned.rs", 0.01);
1225 ledger.set_state(
1226 "pinned.rs",
1227 crate::core::context_field::ContextState::Pinned,
1228 );
1229 ledger.record("normal.rs", "full", 100, 100);
1230 ledger.update_phi("normal.rs", 0.5);
1231 let candidates = ledger.eviction_candidates_by_phi(1);
1232 assert_eq!(candidates, vec!["normal.rs"]);
1233 }
1234
1235 #[test]
1236 fn mark_stale_by_hash_detects_change() {
1237 let mut ledger = ContextLedger::new();
1238 ledger.record("a.rs", "full", 100, 100);
1239 ledger.entries[0].source_hash = Some("hash_v1".to_string());
1240 ledger.mark_stale_by_hash("a.rs", "hash_v2");
1241 assert_eq!(
1242 ledger.entries[0].state,
1243 Some(crate::core::context_field::ContextState::Stale)
1244 );
1245 }
1246
1247 #[test]
1248 fn find_by_id_works() {
1249 let mut ledger = ContextLedger::new();
1250 ledger.record("src/lib.rs", "full", 100, 100);
1251 let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1252 assert!(ledger.find_by_id(&id).is_some());
1253 }
1254
1255 #[test]
1256 fn phi_recomputed_on_reread_not_sticky() {
1257 let _env = crate::core::data_dir::test_env_lock();
1259 let dir = tempfile::tempdir().unwrap();
1260 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1261
1262 let mut ledger = ContextLedger::with_window_size(100_000);
1263 ledger.record_with_task(
1265 "src/authentication.rs",
1266 "full",
1267 2000,
1268 2000,
1269 Some("fix authentication login flow"),
1270 );
1271 let phi_with_task = ledger.entries[0].phi.unwrap();
1272 ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1275 let phi_after = ledger.entries[0].phi.unwrap();
1276 assert_ne!(
1277 phi_with_task, phi_after,
1278 "Phi must be recomputed on re-read (#2)"
1279 );
1280 assert!(
1281 phi_after < phi_with_task,
1282 "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1283 );
1284 }
1285
1286 #[test]
1289 fn resolve_entry_matches_basename_suffix_uniquely() {
1290 let mut ledger = ContextLedger::with_window_size(10000);
1291 ledger.record("/home/user/proj/src/context_ledger.rs", "full", 500, 500);
1292 ledger.record("/home/user/proj/src/other.rs", "full", 300, 300);
1293
1294 assert_eq!(
1296 ledger.resolve_entry("context_ledger.rs", None),
1297 LedgerResolution::Unique(0)
1298 );
1299 assert_eq!(
1301 ledger.resolve_entry("src/other.rs", None),
1302 LedgerResolution::Unique(1)
1303 );
1304 assert_eq!(
1306 ledger.resolve_entry("edger.rs", None),
1307 LedgerResolution::NotFound
1308 );
1309 }
1310
1311 #[test]
1312 fn resolve_entry_reports_ambiguous_suffix() {
1313 let mut ledger = ContextLedger::with_window_size(10000);
1314 ledger.record("/proj/a/mod.rs", "full", 100, 100);
1315 ledger.record("/proj/b/mod.rs", "full", 100, 100);
1316
1317 match ledger.resolve_entry("mod.rs", None) {
1318 LedgerResolution::Ambiguous(candidates) => {
1319 assert_eq!(candidates.len(), 2);
1320 assert!(candidates.contains(&"/proj/a/mod.rs".to_string()));
1321 }
1322 other => panic!("expected Ambiguous, got {other:?}"),
1323 }
1324 assert_eq!(
1326 ledger.resolve_entry("a/mod.rs", None),
1327 LedgerResolution::Unique(0)
1328 );
1329 }
1330
1331 #[test]
1332 fn resolve_entry_prefers_project_root_relative() {
1333 let mut ledger = ContextLedger::with_window_size(10000);
1334 ledger.record("/work/proj/src/lib.rs", "full", 100, 100);
1335 ledger.record("/elsewhere/src/lib.rs", "full", 100, 100);
1336
1337 assert!(matches!(
1339 ledger.resolve_entry("src/lib.rs", None),
1340 LedgerResolution::Ambiguous(_)
1341 ));
1342 assert_eq!(
1343 ledger.resolve_entry("src/lib.rs", Some("/work/proj")),
1344 LedgerResolution::Unique(0)
1345 );
1346 }
1347
1348 #[test]
1349 fn resolve_entry_handles_windows_separators() {
1350 let mut ledger = ContextLedger::with_window_size(10000);
1351 ledger.entries.push(LedgerEntry {
1353 path: "C:/Users/dev/proj/src/main.rs".to_string(),
1354 mode: "full".to_string(),
1355 original_tokens: 100,
1356 sent_tokens: 100,
1357 timestamp: chrono::Utc::now().timestamp(),
1358 id: None,
1359 kind: None,
1360 source_hash: None,
1361 state: None,
1362 phi: None,
1363 view_costs: None,
1364 active_view: None,
1365 provenance: None,
1366 access_count: 1,
1367 });
1368 ledger.total_tokens_sent = 100;
1369
1370 assert_eq!(
1372 ledger.resolve_entry("src\\main.rs", None),
1373 LedgerResolution::Unique(0)
1374 );
1375 assert_eq!(ledger.evict_paths(&["src\\main.rs"]), 1);
1376 assert!(ledger.entries.is_empty());
1377 }
1378
1379 #[test]
1380 fn evict_paths_resolved_reports_outcomes() {
1381 let mut ledger = ContextLedger::with_window_size(10000);
1382 ledger.record("/proj/src/gate.rs", "full", 500, 500);
1383 ledger.record("/proj/a/dup.rs", "full", 100, 100);
1384 ledger.record("/proj/b/dup.rs", "full", 100, 100);
1385
1386 let outcomes =
1387 ledger.evict_paths_resolved(&["gate.rs", "dup.rs", "missing.rs"], Some("/proj"));
1388 assert_eq!(
1389 outcomes[0].resolved.as_deref(),
1390 Some("/proj/src/gate.rs"),
1391 "basename must resolve and evict"
1392 );
1393 assert!(outcomes[1].resolved.is_none());
1394 assert_eq!(outcomes[1].ambiguous.len(), 2, "ambiguity is diagnosed");
1395 assert!(outcomes[2].resolved.is_none());
1396 assert!(outcomes[2].ambiguous.is_empty());
1397 assert_eq!(ledger.entries.len(), 2, "only the unique match is removed");
1398 assert_eq!(ledger.total_tokens_sent, 200);
1399 }
1400
1401 #[test]
1402 fn set_state_resolves_partial_paths() {
1403 let mut ledger = ContextLedger::new();
1404 ledger.record("/proj/src/deep/file.rs", "full", 100, 100);
1405 ledger.set_state("file.rs", crate::core::context_field::ContextState::Pinned);
1406 assert_eq!(
1407 ledger.entries[0].state,
1408 Some(crate::core::context_field::ContextState::Pinned)
1409 );
1410 }
1411
1412 #[test]
1413 fn upsert_sets_source_hash_and_kind() {
1414 let mut ledger = ContextLedger::new();
1415 ledger.upsert(
1416 "src/main.rs",
1417 "full",
1418 500,
1419 500,
1420 Some("sha256_abc"),
1421 crate::core::context_field::ContextKind::File,
1422 None,
1423 );
1424 let entry = &ledger.entries[0];
1425 assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1426 assert_eq!(
1427 entry.kind,
1428 Some(crate::core::context_field::ContextKind::File)
1429 );
1430 }
1431}