1use std::collections::HashMap;
2
3use crate::core::context_field::{
4 ContextItemId, ContextKind, ContextState, Provenance, ViewCosts, ViewKind,
5};
6
7use super::helpers::{
8 DEFAULT_CONTEXT_WINDOW, GWT_MIN_ENTRIES, PHI_REREAD_ALPHA, acquire_ledger_lock,
9 atomic_write_json, ledger_path,
10};
11use super::reinjection::ignition_z_threshold;
12use super::types::{
13 ContextLedger, ContextPressure, EvictOutcome, LedgerEntry, LedgerResolution, PressureAction,
14};
15
16impl ContextLedger {
17 pub fn new() -> Self {
18 Self {
19 window_size: DEFAULT_CONTEXT_WINDOW,
20 entries: Vec::new(),
21 total_tokens_sent: 0,
22 total_tokens_saved: 0,
23 last_flush: None,
24 }
25 }
26
27 pub fn with_window_size(size: usize) -> Self {
28 Self {
29 window_size: size,
30 entries: Vec::new(),
31 total_tokens_sent: 0,
32 total_tokens_saved: 0,
33 last_flush: None,
34 }
35 }
36
37 pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
38 self.record_with_task(path, mode, original_tokens, sent_tokens, None);
39 }
40
41 pub fn record_shell(&mut self, command: &str, original_tokens: usize, sent_tokens: usize) {
42 self.record_with_task(command, "shell", original_tokens, sent_tokens, None);
43 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == command) {
44 entry.kind = Some(ContextKind::Shell);
45 }
46 }
47
48 pub fn record_with_task(
49 &mut self,
50 path: &str,
51 mode: &str,
52 original_tokens: usize,
53 sent_tokens: usize,
54 task: Option<&str>,
55 ) {
56 let path = crate::core::pathutil::normalize_tool_path(path);
57 let item_id = ContextItemId::from_file(&path);
58
59 let phi =
60 Self::compute_real_phi(&path, sent_tokens, original_tokens, self.window_size, task);
61
62 if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
63 self.total_tokens_sent -= existing.sent_tokens;
64 self.total_tokens_saved -= existing
65 .original_tokens
66 .saturating_sub(existing.sent_tokens);
67 existing.mode = mode.to_string();
68 existing.original_tokens = original_tokens;
69 existing.sent_tokens = sent_tokens;
70 existing.timestamp = chrono::Utc::now().timestamp();
71 existing.access_count = existing.access_count.saturating_add(1);
72 existing.active_view = Some(ViewKind::parse(mode));
73 if existing.id.is_none() {
74 existing.id = Some(item_id);
75 }
76 if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
77 existing.state = Some(ContextState::Included);
78 }
79 existing.phi = Some(match existing.phi {
85 Some(old) => PHI_REREAD_ALPHA * phi + (1.0 - PHI_REREAD_ALPHA) * old,
86 None => phi,
87 });
88 crate::core::introspect::tick("phi_recompute");
89 } else {
90 self.entries.push(LedgerEntry {
91 path: path.clone(),
92 mode: mode.to_string(),
93 original_tokens,
94 sent_tokens,
95 timestamp: chrono::Utc::now().timestamp(),
96 id: Some(item_id),
97 kind: Some(ContextKind::File),
98 source_hash: None,
99 state: Some(ContextState::Included),
100 phi: Some(phi),
101 view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
102 active_view: Some(ViewKind::parse(mode)),
103 provenance: None,
104 access_count: 1,
105 });
106 }
107 self.total_tokens_sent += sent_tokens;
108 self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
109 }
110
111 fn compute_real_phi(
112 path: &str,
113 sent_tokens: usize,
114 original_tokens: usize,
115 window_size: usize,
116 task: Option<&str>,
117 ) -> f64 {
118 use crate::core::context_field::{ContextField, compute_signals_for_path};
119
120 let (signals, _costs) =
121 compute_signals_for_path(path, task, None, window_size, original_tokens);
122 let phi = ContextField::new().compute_phi(&signals);
123 if phi > 0.0 {
124 return phi;
125 }
126
127 Self::compute_lightweight_phi(sent_tokens, window_size)
128 }
129
130 fn compute_lightweight_phi(sent_tokens: usize, window_size: usize) -> f64 {
131 use crate::core::context_field::{ContextField, FieldSignals};
132 let token_cost_norm = if window_size > 0 {
133 (sent_tokens as f64 / window_size as f64).min(1.0)
134 } else {
135 0.0
136 };
137 let signals = FieldSignals {
138 relevance: 1.0,
139 surprise: 0.5,
140 graph_proximity: 0.0,
141 history_signal: 0.0,
142 token_cost_norm,
143 redundancy: 0.0,
144 };
145 ContextField::new().compute_phi(&signals)
146 }
147
148 pub fn upsert(
150 &mut self,
151 path: &str,
152 mode: &str,
153 original_tokens: usize,
154 sent_tokens: usize,
155 source_hash: Option<&str>,
156 kind: ContextKind,
157 provenance: Option<Provenance>,
158 ) {
159 self.record(path, mode, original_tokens, sent_tokens);
160 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
161 entry.kind = Some(kind);
162 if let Some(h) = source_hash
163 && entry.source_hash.as_deref() != Some(h)
164 {
165 if entry.source_hash.is_some() {
166 entry.state = Some(ContextState::Stale);
167 }
168 entry.source_hash = Some(h.to_string());
169 }
170 if let Some(prov) = provenance {
171 entry.provenance = Some(prov);
172 }
173 }
174 }
175
176 pub fn update_phi(&mut self, path: &str, phi: f64) {
178 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
179 entry.phi = Some(phi);
180 }
181 }
182
183 pub fn set_state(&mut self, path: &str, state: ContextState) {
186 if let LedgerResolution::Unique(idx) = self.resolve_entry(path, None) {
187 self.entries[idx].state = Some(state);
188 }
189 }
190
191 pub fn resolve_entry(&self, target: &str, project_root: Option<&str>) -> LedgerResolution {
197 let lex = crate::core::pathutil::normalize_tool_path_lexical(target);
198 if lex.is_empty() {
199 return LedgerResolution::NotFound;
200 }
201
202 if let Some(idx) = self.entries.iter().position(|e| e.path == lex) {
205 return LedgerResolution::Unique(idx);
206 }
207 let full = crate::core::pathutil::normalize_tool_path(target);
208 if full != lex
209 && let Some(idx) = self.entries.iter().position(|e| e.path == full)
210 {
211 return LedgerResolution::Unique(idx);
212 }
213
214 if let Some(root) = project_root.filter(|r| !r.is_empty()) {
216 let joined = format!(
217 "{}/{}",
218 root.trim_end_matches(['/', '\\']),
219 lex.trim_start_matches('/')
220 );
221 let joined_lex = crate::core::pathutil::normalize_tool_path_lexical(&joined);
222 if let Some(idx) = self.entries.iter().position(|e| e.path == joined_lex) {
223 return LedgerResolution::Unique(idx);
224 }
225 let joined_full = crate::core::pathutil::normalize_tool_path(&joined_lex);
226 if joined_full != joined_lex
227 && let Some(idx) = self.entries.iter().position(|e| e.path == joined_full)
228 {
229 return LedgerResolution::Unique(idx);
230 }
231 }
232
233 let suffix = format!("/{}", lex.trim_start_matches('/'));
236 let matches: Vec<usize> = self
237 .entries
238 .iter()
239 .enumerate()
240 .filter(|(_, e)| e.path.ends_with(&suffix))
241 .map(|(idx, _)| idx)
242 .collect();
243 match matches.len() {
244 1 => LedgerResolution::Unique(matches[0]),
245 0 => LedgerResolution::NotFound,
246 _ => LedgerResolution::Ambiguous(
247 matches
248 .iter()
249 .map(|&idx| self.entries[idx].path.clone())
250 .collect(),
251 ),
252 }
253 }
254
255 pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
257 self.entries.iter().find(|e| e.id.as_ref() == Some(id))
258 }
259
260 pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
262 self.entries
263 .iter()
264 .filter(|e| e.state == Some(state))
265 .collect()
266 }
267
268 pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
271 if self.entries.len() <= keep_count {
272 return Vec::new();
273 }
274 let mut sorted = self.entries.clone();
275 sorted.sort_by(|a, b| {
276 let a_phi = a.phi.unwrap_or(0.0);
277 let b_phi = b.phi.unwrap_or(0.0);
278 a_phi
279 .partial_cmp(&b_phi)
280 .unwrap_or(std::cmp::Ordering::Equal)
281 .then_with(|| a.timestamp.cmp(&b.timestamp))
282 });
283 sorted
284 .iter()
285 .filter(|e| e.state != Some(ContextState::Pinned))
286 .take(self.entries.len() - keep_count)
287 .map(|e| e.path.clone())
288 .collect()
289 }
290
291 pub fn ignite_high_salience(&mut self) -> Vec<String> {
298 let z_threshold = ignition_z_threshold();
299 let phis: Vec<f64> = self.entries.iter().filter_map(|e| e.phi).collect();
300 if phis.len() < GWT_MIN_ENTRIES {
301 return Vec::new();
302 }
303 let n = phis.len() as f64;
304 let mean = phis.iter().sum::<f64>() / n;
305 let var = phis.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n;
306 let std = var.sqrt();
307 if std <= f64::EPSILON {
308 return Vec::new();
309 }
310
311 let mut ignited = Vec::new();
312 for e in &mut self.entries {
313 let Some(phi) = e.phi else { continue };
314 let state = e.state.unwrap_or(ContextState::Included);
315 if matches!(state, ContextState::Excluded | ContextState::Pinned) {
316 continue;
317 }
318 if (phi - mean) / std > z_threshold {
319 e.state = Some(ContextState::Pinned);
320 ignited.push(e.path.clone());
321 }
322 }
323 if !ignited.is_empty() {
324 crate::core::introspect::tick("gwt_ignition");
325 }
326 ignited
327 }
328
329 pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
331 if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path)
332 && let Some(ref old_hash) = entry.source_hash
333 && old_hash != new_hash
334 {
335 entry.state = Some(ContextState::Stale);
336 entry.source_hash = Some(new_hash.to_string());
337 }
338 }
339
340 pub fn pressure(&self) -> ContextPressure {
341 let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
342
343 let pinned_count = self
344 .entries
345 .iter()
346 .filter(|e| e.state == Some(ContextState::Pinned))
347 .count();
348 let stale_count = self
349 .entries
350 .iter()
351 .filter(|e| e.state == Some(ContextState::Stale))
352 .count();
353 let pinned_pressure = pinned_count as f64 * 0.02;
354 let stale_penalty = stale_count as f64 * 0.01;
355 const MAX_STATE_PRESSURE: f64 = 0.2;
361 let effective_utilization =
362 (utilization + (pinned_pressure + stale_penalty).min(MAX_STATE_PRESSURE)).min(1.0);
363
364 let remaining = self.window_size.saturating_sub(self.total_tokens_sent);
372
373 let recommendation = if effective_utilization > 0.9 {
374 PressureAction::EvictLeastRelevant
375 } else if effective_utilization > 0.75 {
376 PressureAction::ForceCompression
377 } else if effective_utilization > 0.5 {
378 PressureAction::SuggestCompression
379 } else {
380 PressureAction::NoAction
381 };
382
383 ContextPressure {
384 utilization: effective_utilization,
385 remaining_tokens: remaining,
386 entries_count: self.entries.len(),
387 recommendation,
388 }
389 }
390
391 pub fn compression_ratio(&self) -> f64 {
392 let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
393 if total_original == 0 {
394 return 1.0;
395 }
396 self.total_tokens_sent as f64 / total_original as f64
397 }
398
399 pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
400 let mut costs: Vec<(String, usize)> = self
401 .entries
402 .iter()
403 .map(|e| (e.path.clone(), e.sent_tokens))
404 .collect();
405 costs.sort_by_key(|b| std::cmp::Reverse(b.1));
406 costs
407 }
408
409 pub fn mode_distribution(&self) -> HashMap<String, usize> {
410 let mut dist: HashMap<String, usize> = HashMap::new();
411 for entry in &self.entries {
412 *dist.entry(entry.mode.clone()).or_insert(0) += 1;
413 }
414 dist
415 }
416
417 pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
418 if self.entries.len() <= keep_count {
419 return Vec::new();
420 }
421 let mut sorted = self.entries.clone();
422 sorted.sort_by_key(|e| e.timestamp);
423 sorted
424 .iter()
425 .take(self.entries.len() - keep_count)
426 .map(|e| e.path.clone())
427 .collect()
428 }
429
430 pub fn remove(&mut self, path: &str) -> bool {
433 match self.resolve_entry(path, None) {
434 LedgerResolution::Unique(idx) => {
435 self.remove_at(idx);
436 true
437 }
438 _ => false,
439 }
440 }
441
442 fn remove_at(&mut self, idx: usize) {
443 let entry = &self.entries[idx];
444 self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
445 self.total_tokens_saved = self
446 .total_tokens_saved
447 .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
448 self.entries.remove(idx);
449 }
450
451 pub fn reset(&mut self) {
453 let pinned_count = self
454 .entries
455 .iter()
456 .filter(|e| e.state == Some(ContextState::Pinned))
457 .count();
458 self.entries.clear();
459 self.total_tokens_sent = 0;
460 self.total_tokens_saved = 0;
461 if pinned_count > 0 {
462 tracing::info!("{pinned_count} pinned entries were also cleared");
463 }
464 }
465
466 pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
469 self.evict_paths_resolved(paths, None)
470 .iter()
471 .filter(|o| o.resolved.is_some())
472 .count()
473 }
474
475 pub fn evict_paths_resolved(
480 &mut self,
481 paths: &[&str],
482 project_root: Option<&str>,
483 ) -> Vec<EvictOutcome> {
484 paths
485 .iter()
486 .map(|target| match self.resolve_entry(target, project_root) {
487 LedgerResolution::Unique(idx) => {
488 let resolved = self.entries[idx].path.clone();
489 self.remove_at(idx);
490 EvictOutcome {
491 target: (*target).to_string(),
492 resolved: Some(resolved),
493 ambiguous: Vec::new(),
494 }
495 }
496 LedgerResolution::Ambiguous(candidates) => EvictOutcome {
497 target: (*target).to_string(),
498 resolved: None,
499 ambiguous: candidates,
500 },
501 LedgerResolution::NotFound => EvictOutcome {
502 target: (*target).to_string(),
503 resolved: None,
504 ambiguous: Vec::new(),
505 },
506 })
507 .collect()
508 }
509
510 pub fn save(&self) {
511 self.save_for_agent("default");
512 }
513
514 pub fn save_debounced(&mut self) {
517 let now = std::time::Instant::now();
518 if let Some(last) = self.last_flush
519 && now.duration_since(last) < std::time::Duration::from_secs(3)
520 {
521 return;
522 }
523 self.save();
524 self.last_flush = Some(now);
525 }
526
527 pub fn save_for_agent(&self, agent_id: &str) {
528 if let Ok(path) = ledger_path(agent_id) {
529 if let Some(parent) = path.parent() {
530 let _ = std::fs::create_dir_all(parent);
531 }
532 let _lock = acquire_ledger_lock(&path);
533 if let Ok(json) = serde_json::to_string(self) {
534 atomic_write_json(&path, &json);
535 }
536 }
537 }
538
539 const MAX_LEDGER_ENTRIES: usize = 200;
540 const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
541
542 pub fn prune(&mut self) -> usize {
543 let before = self.entries.len();
544 let now = chrono::Utc::now().timestamp();
545
546 for entry in &mut self.entries {
547 if let Some(phi) = entry.phi {
548 let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
549 let decayed = phi * 0.95_f64.powf(hours_since);
550 entry.phi = Some(decayed.max(0.0));
551 }
552 }
553
554 self.entries
555 .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
556
557 self.entries.retain(|e| {
558 let age = now - e.timestamp;
559 let phi = e.phi.unwrap_or(0.0);
560 !(age > Self::STALE_AGE_SECS && phi < 0.1)
561 });
562
563 let mut seen = std::collections::HashSet::new();
564 self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
565 self.entries.retain(|e| {
566 let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
572 seen.insert(key)
573 });
574
575 if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
576 self.entries.sort_by(|a, b| {
577 let pa = a.phi.unwrap_or(0.0);
578 let pb = b.phi.unwrap_or(0.0);
579 pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
580 });
581 self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
582 }
583
584 self.rebuild_totals();
585 before - self.entries.len()
586 }
587
588 fn rebuild_totals(&mut self) {
589 self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
590 self.total_tokens_saved = self
591 .entries
592 .iter()
593 .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
594 .sum();
595 }
596
597 pub fn load() -> Self {
598 Self::load_for_agent("default")
599 }
600
601 pub fn load_for_agent(agent_id: &str) -> Self {
602 let mut ledger: Self = ledger_path(agent_id)
603 .ok()
604 .and_then(|p| {
605 let _lock = acquire_ledger_lock(&p);
606 std::fs::read_to_string(p).ok()
607 })
608 .and_then(|s| serde_json::from_str(&s).ok())
609 .unwrap_or_default();
610 if let Some((_model, window)) = crate::core::host_state::load_detected_model() {
611 ledger.window_size = window;
612 }
613 let mut migrated = false;
617 for entry in &mut ledger.entries {
618 let normalized = crate::core::pathutil::normalize_tool_path_lexical(&entry.path);
619 if normalized != entry.path {
620 entry.path = normalized;
621 migrated = true;
622 }
623 }
624 let pruned = ledger.prune();
625 if pruned > 0 || migrated {
626 ledger.save_for_agent(agent_id);
627 }
628 ledger
629 }
630
631 pub fn format_summary(&self) -> String {
632 let pressure = self.pressure();
633 format!(
634 "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
635 self.total_tokens_sent,
636 self.window_size,
637 pressure.utilization * 100.0,
638 self.entries.len(),
639 self.compression_ratio(),
640 pressure.recommendation,
641 )
642 }
643
644 pub fn adjusted_total_saved(&self) -> isize {
645 match crate::core::bounce_tracker::global().lock() {
646 Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
647 _ => self.total_tokens_saved as isize,
648 }
649 }
650}