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