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