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