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 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 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 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 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 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 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 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}