Skip to main content

lean_ctx/core/context_ledger/
ledger.rs

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            // #2 Sticky-Phi fix: salience is time-variant (recency, task match,
73            // access frequency all changed since the first read), so recompute
74            // Phi on every re-read instead of freezing the first value. Blend
75            // with the prior score via a fixed-alpha EMA — deterministic, and
76            // damped so a single noisy read can't whipsaw eviction order.
77            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        // #4: use the learned (bandit-selected) field weights when available.
116        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    /// Record with full CFT metadata including source hash and provenance.
143    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    /// Update the Phi score for an entry.
171    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    /// Set the state for an entry. Accepts partial paths and basenames via
178    /// [`Self::resolve_entry`] (#715).
179    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    /// Resolve a user-supplied target against the ledger (#715):
186    /// exact match → project-root-relative → unambiguous suffix at a path
187    /// component boundary. Entries are stored as normalized absolute paths
188    /// (forward slashes); targets arrive as basenames, relative paths, or
189    /// OS-native separators.
190    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        // Stage 1 — exact: lexical form first (no FS access), then the fully
197        // canonical form (resolves symlinks, matching what `record` stored).
198        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        // Stage 2 — relative to the project root.
209        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        // Stage 3 — unambiguous suffix at a component boundary ("/…"), so
228        // `main.rs` matches `src/main.rs` but never `domain.rs`.
229        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    /// Find an entry by its ContextItemId.
250    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    /// Get all entries with a specific state.
255    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    /// Eviction candidates ordered by Phi (lowest first), falling back to
263    /// timestamp for entries without Phi scores.
264    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    /// Global-Workspace ignition (#6): context items compete on salience (Phi);
286    /// any whose z-score exceeds the ignition threshold is "broadcast" — promoted
287    /// to Pinned so it survives eviction (`eviction_candidates_by_phi` already
288    /// skips Pinned) and pressure reinjection, and reaches the compiler's working
289    /// set as a pinned candidate. Deterministic: a pure threshold over the current
290    /// Phi distribution, no sampling. Returns the paths newly ignited this call.
291    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    /// Mark entries as stale if their source hash has changed.
324    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        // Pinned/stale entries reduce eviction flexibility, so they nudge
350        // pressure upward — but the nudge must stay bounded. Without a cap, a
351        // long session where many entries end up Pinned (e.g. via GWT
352        // ignition, #6) can alone drive utilization to 100% regardless of
353        // actual token usage.
354        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        // `remaining_tokens` is a literal token-budget figure consumed by
359        // dashboards and the deficit-suggestion auto-loader
360        // (`context_deficit.rs`) as "how much room is actually left" — it
361        // must track real usage, not the heuristic-boosted
362        // `effective_utilization`, or a heavily-pinned/stale session reports
363        // 0 remaining (and silently starves auto-loading) while tokens of
364        // real headroom are still free.
365        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    /// Remove one entry by target. Resolves partial paths and basenames
425    /// (#715); an ambiguous target removes nothing.
426    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    /// Clear all entries and reset totals to zero.
446    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    /// Remove specific paths from the ledger. Returns count of entries removed.
461    /// Targets resolve like [`Self::resolve_entry`] (#715).
462    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    /// Eviction with full per-target diagnostics (#715): resolves each target
470    /// (exact → root-relative → unique suffix) and reports the canonical path
471    /// it removed, or the ambiguous candidates, so callers can surface WHY
472    /// nothing was evicted instead of a bare "Evicted 0/1".
473    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    /// Debounced save: only flushes to disk if >=3s since last save.
509    /// Reduces I/O overhead during burst sequences of tool calls.
510    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            // Lexical key only: entries were normalized when written, and the
561            // full variant would `realpath` every persisted path — the daemon
562            // runs this at boot (ContextLedger::load → prune) and stat-ing
563            // stored paths under ~/Documents from a launchd process pops the
564            // macOS TCC prompt (#356).
565            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        // #715 migration: older Windows builds persisted `\`-separated paths;
608        // resolution and dedup assume the forward-slash canonical form.
609        // Lexical only — no FS access on persisted paths (TCC, #356).
610        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}