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