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_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            // #2 Sticky-Phi fix: salience is time-variant (recency, task match,
80            // access frequency all changed since the first read), so recompute
81            // Phi on every re-read instead of freezing the first value. Blend
82            // with the prior score via a fixed-alpha EMA — deterministic, and
83            // damped so a single noisy read can't whipsaw eviction order.
84            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    /// Record with full CFT metadata including source hash and provenance.
149    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    /// Update the Phi score for an entry.
177    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    /// Set the state for an entry. Accepts partial paths and basenames via
184    /// [`Self::resolve_entry`] (#715).
185    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    /// Resolve a user-supplied target against the ledger (#715):
192    /// exact match → project-root-relative → unambiguous suffix at a path
193    /// component boundary. Entries are stored as normalized absolute paths
194    /// (forward slashes); targets arrive as basenames, relative paths, or
195    /// OS-native separators.
196    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        // Stage 1 — exact: lexical form first (no FS access), then the fully
203        // canonical form (resolves symlinks, matching what `record` stored).
204        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        // Stage 2 — relative to the project root.
215        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        // Stage 3 — unambiguous suffix at a component boundary ("/…"), so
234        // `main.rs` matches `src/main.rs` but never `domain.rs`.
235        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    /// Find an entry by its ContextItemId.
256    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    /// Get all entries with a specific state.
261    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    /// Eviction candidates ordered by Phi (lowest first), falling back to
269    /// timestamp for entries without Phi scores.
270    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    /// Global-Workspace ignition (#6): context items compete on salience (Phi);
292    /// any whose z-score exceeds the ignition threshold is "broadcast" — promoted
293    /// to Pinned so it survives eviction (`eviction_candidates_by_phi` already
294    /// skips Pinned) and pressure reinjection, and reaches the compiler's working
295    /// set as a pinned candidate. Deterministic: a pure threshold over the current
296    /// Phi distribution, no sampling. Returns the paths newly ignited this call.
297    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    /// Mark entries as stale if their source hash has changed.
330    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        // Pinned/stale entries reduce eviction flexibility, so they nudge
356        // pressure upward — but the nudge must stay bounded. Without a cap, a
357        // long session where many entries end up Pinned (e.g. via GWT
358        // ignition, #6) can alone drive utilization to 100% regardless of
359        // actual token usage.
360        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        // `remaining_tokens` is a literal token-budget figure consumed by
365        // dashboards and the deficit-suggestion auto-loader
366        // (`context_deficit.rs`) as "how much room is actually left" — it
367        // must track real usage, not the heuristic-boosted
368        // `effective_utilization`, or a heavily-pinned/stale session reports
369        // 0 remaining (and silently starves auto-loading) while tokens of
370        // real headroom are still free.
371        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    /// Remove one entry by target. Resolves partial paths and basenames
431    /// (#715); an ambiguous target removes nothing.
432    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    /// Clear all entries and reset totals to zero.
452    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    /// Remove specific paths from the ledger. Returns count of entries removed.
467    /// Targets resolve like [`Self::resolve_entry`] (#715).
468    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    /// Eviction with full per-target diagnostics (#715): resolves each target
476    /// (exact → root-relative → unique suffix) and reports the canonical path
477    /// it removed, or the ambiguous candidates, so callers can surface WHY
478    /// nothing was evicted instead of a bare "Evicted 0/1".
479    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    /// Debounced save: only flushes to disk if >=3s since last save.
515    /// Reduces I/O overhead during burst sequences of tool calls.
516    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            // Lexical key only: entries were normalized when written, and the
567            // full variant would `realpath` every persisted path — the daemon
568            // runs this at boot (ContextLedger::load → prune) and stat-ing
569            // stored paths under ~/Documents from a launchd process pops the
570            // macOS TCC prompt (#356).
571            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        // #715 migration: older Windows builds persisted `\`-separated paths;
614        // resolution and dedup assume the forward-slash canonical form.
615        // Lexical only — no FS access on persisted paths (TCC, #356).
616        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}