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