Skip to main content

lean_ctx/core/
context_ledger.rs

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