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