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