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