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