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