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