Skip to main content

lean_ctx/core/
context_ledger.rs

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
11/// EMA weight for the freshly computed Phi on a re-read (#2). 0.5 keeps equal
12/// weight on the new signal and the running history, so salience tracks recency
13/// and task changes without overreacting to one read.
14const PHI_REREAD_ALPHA: f64 = 0.5;
15
16/// Default Global-Workspace ignition threshold (#6) as a Phi z-score: an item
17/// must stand more than this many standard deviations above the mean salience to
18/// "ignite" and be broadcast (promoted to Pinned) into the global workspace.
19const GWT_IGNITION_Z: f64 = 1.5;
20/// Minimum number of scored entries before ignition can fire — below this the
21/// Phi distribution is too small to identify a meaningful outlier, so ignition
22/// is suppressed to avoid pinning everything on a cold ledger.
23const GWT_MIN_ENTRIES: usize = 4;
24
25fn ledger_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
26    let dir = crate::core::paths::state_dir()?;
27    if agent_id == "default" {
28        Ok(dir.join("context_ledger.json"))
29    } else {
30        let ledger_dir = dir.join("ledger");
31        let safe_id: String = agent_id
32            .chars()
33            .map(|c| {
34                if c.is_alphanumeric() || c == '-' || c == '_' {
35                    c
36                } else {
37                    '_'
38                }
39            })
40            .collect();
41        Ok(ledger_dir.join(format!("{safe_id}.json")))
42    }
43}
44
45fn atomic_write_json(path: &std::path::Path, data: &str) {
46    let _ = crate::config_io::write_atomic(path, data);
47}
48
49/// Acquire an advisory file lock for cross-process safety.
50/// Returns the lock file handle (lock released on drop).
51#[cfg(unix)]
52fn acquire_ledger_lock(path: &std::path::Path) -> Option<std::fs::File> {
53    use std::os::unix::io::AsRawFd;
54    let lock_path = path.with_extension("json.lock");
55    let file = std::fs::OpenOptions::new()
56        .create(true)
57        .write(true)
58        .truncate(false)
59        .open(&lock_path)
60        .ok()?;
61    let fd = file.as_raw_fd();
62    // SAFETY: `fd` is a valid open descriptor owned by `file`, which outlives
63    // this call; `flock` dereferences no pointers.
64    let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
65    if ret != 0 {
66        // Lock held — block up to 2s
67        use std::time::{Duration, Instant};
68        let deadline = Instant::now() + Duration::from_secs(2);
69        loop {
70            std::thread::sleep(Duration::from_millis(50));
71            // SAFETY: `fd` is still a valid open descriptor owned by `file`,
72            // which outlives this call; `flock` dereferences no pointers.
73            let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
74            if ret == 0 {
75                break;
76            }
77            if Instant::now() >= deadline {
78                return None;
79            }
80        }
81    }
82    Some(file)
83}
84
85#[cfg(not(unix))]
86fn acquire_ledger_lock(_path: &std::path::Path) -> Option<std::fs::File> {
87    None
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ContextLedger {
92    pub window_size: usize,
93    pub entries: Vec<LedgerEntry>,
94    pub total_tokens_sent: usize,
95    pub total_tokens_saved: usize,
96    #[serde(skip)]
97    last_flush: Option<std::time::Instant>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct LedgerEntry {
102    pub path: String,
103    pub mode: String,
104    pub original_tokens: usize,
105    pub sent_tokens: usize,
106    pub timestamp: i64,
107    #[serde(default)]
108    pub id: Option<ContextItemId>,
109    #[serde(default)]
110    pub kind: Option<ContextKind>,
111    #[serde(default)]
112    pub source_hash: Option<String>,
113    #[serde(default)]
114    pub state: Option<ContextState>,
115    #[serde(default)]
116    pub phi: Option<f64>,
117    #[serde(default)]
118    pub view_costs: Option<ViewCosts>,
119    #[serde(default)]
120    pub active_view: Option<ViewKind>,
121    #[serde(default)]
122    pub provenance: Option<Provenance>,
123    /// How many times this item has been (re)read into context. Drives the
124    /// "high tokens + low recent use" eviction-candidate heuristic.
125    #[serde(default)]
126    pub access_count: u32,
127}
128
129#[derive(Debug, Clone)]
130pub struct ContextPressure {
131    pub utilization: f64,
132    pub remaining_tokens: usize,
133    pub entries_count: usize,
134    pub recommendation: PressureAction,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum PressureAction {
139    NoAction,
140    SuggestCompression,
141    ForceCompression,
142    EvictLeastRelevant,
143}
144
145/// Outcome of resolving a user-supplied target against the ledger (#715).
146/// Entries store absolute canonical paths while users, hints and the
147/// dashboard supply relative paths or basenames — exact matching alone made
148/// `evict` a no-op ("Evicted 0/1").
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum LedgerResolution {
151    /// Exactly one entry matched (index into `entries`).
152    Unique(usize),
153    /// Several entries share the suffix — the matched paths, for diagnostics.
154    Ambiguous(Vec<String>),
155    NotFound,
156}
157
158/// Per-target eviction outcome (#715): carries the canonical resolved path so
159/// callers can report precisely what happened and write overlays against the
160/// path the ledger actually stores.
161#[derive(Debug, Clone)]
162pub struct EvictOutcome {
163    pub target: String,
164    pub resolved: Option<String>,
165    pub ambiguous: Vec<String>,
166}
167
168impl ContextLedger {
169    pub fn new() -> Self {
170        Self {
171            window_size: DEFAULT_CONTEXT_WINDOW,
172            entries: Vec::new(),
173            total_tokens_sent: 0,
174            total_tokens_saved: 0,
175            last_flush: None,
176        }
177    }
178
179    pub fn with_window_size(size: usize) -> Self {
180        Self {
181            window_size: size,
182            entries: Vec::new(),
183            total_tokens_sent: 0,
184            total_tokens_saved: 0,
185            last_flush: None,
186        }
187    }
188
189    pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
190        self.record_with_task(path, mode, original_tokens, sent_tokens, None);
191    }
192
193    pub fn record_with_task(
194        &mut self,
195        path: &str,
196        mode: &str,
197        original_tokens: usize,
198        sent_tokens: usize,
199        task: Option<&str>,
200    ) {
201        let path = crate::core::pathutil::normalize_tool_path(path);
202        let item_id = ContextItemId::from_file(&path);
203
204        let phi =
205            Self::compute_real_phi(&path, sent_tokens, original_tokens, self.window_size, task);
206
207        if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
208            self.total_tokens_sent -= existing.sent_tokens;
209            self.total_tokens_saved -= existing
210                .original_tokens
211                .saturating_sub(existing.sent_tokens);
212            existing.mode = mode.to_string();
213            existing.original_tokens = original_tokens;
214            existing.sent_tokens = sent_tokens;
215            existing.timestamp = chrono::Utc::now().timestamp();
216            existing.access_count = existing.access_count.saturating_add(1);
217            existing.active_view = Some(ViewKind::parse(mode));
218            if existing.id.is_none() {
219                existing.id = Some(item_id);
220            }
221            if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
222                existing.state = Some(ContextState::Included);
223            }
224            // #2 Sticky-Phi fix: salience is time-variant (recency, task match,
225            // access frequency all changed since the first read), so recompute
226            // Phi on every re-read instead of freezing the first value. Blend
227            // with the prior score via a fixed-alpha EMA — deterministic, and
228            // damped so a single noisy read can't whipsaw eviction order.
229            existing.phi = Some(match existing.phi {
230                Some(old) => PHI_REREAD_ALPHA * phi + (1.0 - PHI_REREAD_ALPHA) * old,
231                None => phi,
232            });
233            crate::core::introspect::tick("phi_recompute");
234        } else {
235            self.entries.push(LedgerEntry {
236                path: path.clone(),
237                mode: mode.to_string(),
238                original_tokens,
239                sent_tokens,
240                timestamp: chrono::Utc::now().timestamp(),
241                id: Some(item_id),
242                kind: Some(ContextKind::File),
243                source_hash: None,
244                state: Some(ContextState::Included),
245                phi: Some(phi),
246                view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
247                active_view: Some(ViewKind::parse(mode)),
248                provenance: None,
249                access_count: 1,
250            });
251        }
252        self.total_tokens_sent += sent_tokens;
253        self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
254    }
255
256    fn compute_real_phi(
257        path: &str,
258        sent_tokens: usize,
259        original_tokens: usize,
260        window_size: usize,
261        task: Option<&str>,
262    ) -> f64 {
263        use crate::core::context_field::{ContextField, compute_signals_for_path};
264
265        let (signals, _costs) =
266            compute_signals_for_path(path, task, None, window_size, original_tokens);
267        // #4: use the learned (bandit-selected) field weights when available.
268        let phi = ContextField::active().compute_phi(&signals);
269        if phi > 0.0 {
270            return phi;
271        }
272
273        Self::compute_lightweight_phi(sent_tokens, window_size)
274    }
275
276    fn compute_lightweight_phi(sent_tokens: usize, window_size: usize) -> f64 {
277        use crate::core::context_field::{ContextField, FieldSignals};
278        let token_cost_norm = if window_size > 0 {
279            (sent_tokens as f64 / window_size as f64).min(1.0)
280        } else {
281            0.0
282        };
283        let signals = FieldSignals {
284            relevance: 1.0,
285            surprise: 0.5,
286            graph_proximity: 0.0,
287            history_signal: 0.0,
288            token_cost_norm,
289            redundancy: 0.0,
290        };
291        ContextField::active().compute_phi(&signals)
292    }
293
294    /// Record with full CFT metadata including source hash and provenance.
295    pub fn upsert(
296        &mut self,
297        path: &str,
298        mode: &str,
299        original_tokens: usize,
300        sent_tokens: usize,
301        source_hash: Option<&str>,
302        kind: ContextKind,
303        provenance: Option<Provenance>,
304    ) {
305        self.record(path, mode, original_tokens, sent_tokens);
306        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
307            entry.kind = Some(kind);
308            if let Some(h) = source_hash
309                && entry.source_hash.as_deref() != Some(h)
310            {
311                if entry.source_hash.is_some() {
312                    entry.state = Some(ContextState::Stale);
313                }
314                entry.source_hash = Some(h.to_string());
315            }
316            if let Some(prov) = provenance {
317                entry.provenance = Some(prov);
318            }
319        }
320    }
321
322    /// Update the Phi score for an entry.
323    pub fn update_phi(&mut self, path: &str, phi: f64) {
324        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
325            entry.phi = Some(phi);
326        }
327    }
328
329    /// Set the state for an entry. Accepts partial paths and basenames via
330    /// [`Self::resolve_entry`] (#715).
331    pub fn set_state(&mut self, path: &str, state: ContextState) {
332        if let LedgerResolution::Unique(idx) = self.resolve_entry(path, None) {
333            self.entries[idx].state = Some(state);
334        }
335    }
336
337    /// Resolve a user-supplied target against the ledger (#715):
338    /// exact match → project-root-relative → unambiguous suffix at a path
339    /// component boundary. Entries are stored as normalized absolute paths
340    /// (forward slashes); targets arrive as basenames, relative paths, or
341    /// OS-native separators.
342    pub fn resolve_entry(&self, target: &str, project_root: Option<&str>) -> LedgerResolution {
343        let lex = crate::core::pathutil::normalize_tool_path_lexical(target);
344        if lex.is_empty() {
345            return LedgerResolution::NotFound;
346        }
347
348        // Stage 1 — exact: lexical form first (no FS access), then the fully
349        // canonical form (resolves symlinks, matching what `record` stored).
350        if let Some(idx) = self.entries.iter().position(|e| e.path == lex) {
351            return LedgerResolution::Unique(idx);
352        }
353        let full = crate::core::pathutil::normalize_tool_path(target);
354        if full != lex
355            && let Some(idx) = self.entries.iter().position(|e| e.path == full)
356        {
357            return LedgerResolution::Unique(idx);
358        }
359
360        // Stage 2 — relative to the project root.
361        if let Some(root) = project_root.filter(|r| !r.is_empty()) {
362            let joined = format!(
363                "{}/{}",
364                root.trim_end_matches(['/', '\\']),
365                lex.trim_start_matches('/')
366            );
367            let joined_lex = crate::core::pathutil::normalize_tool_path_lexical(&joined);
368            if let Some(idx) = self.entries.iter().position(|e| e.path == joined_lex) {
369                return LedgerResolution::Unique(idx);
370            }
371            let joined_full = crate::core::pathutil::normalize_tool_path(&joined_lex);
372            if joined_full != joined_lex
373                && let Some(idx) = self.entries.iter().position(|e| e.path == joined_full)
374            {
375                return LedgerResolution::Unique(idx);
376            }
377        }
378
379        // Stage 3 — unambiguous suffix at a component boundary ("/…"), so
380        // `main.rs` matches `src/main.rs` but never `domain.rs`.
381        let suffix = format!("/{}", lex.trim_start_matches('/'));
382        let matches: Vec<usize> = self
383            .entries
384            .iter()
385            .enumerate()
386            .filter(|(_, e)| e.path.ends_with(&suffix))
387            .map(|(idx, _)| idx)
388            .collect();
389        match matches.len() {
390            1 => LedgerResolution::Unique(matches[0]),
391            0 => LedgerResolution::NotFound,
392            _ => LedgerResolution::Ambiguous(
393                matches
394                    .iter()
395                    .map(|&idx| self.entries[idx].path.clone())
396                    .collect(),
397            ),
398        }
399    }
400
401    /// Find an entry by its ContextItemId.
402    pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
403        self.entries.iter().find(|e| e.id.as_ref() == Some(id))
404    }
405
406    /// Get all entries with a specific state.
407    pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
408        self.entries
409            .iter()
410            .filter(|e| e.state == Some(state))
411            .collect()
412    }
413
414    /// Eviction candidates ordered by Phi (lowest first), falling back to
415    /// timestamp for entries without Phi scores.
416    pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
417        if self.entries.len() <= keep_count {
418            return Vec::new();
419        }
420        let mut sorted = self.entries.clone();
421        sorted.sort_by(|a, b| {
422            let a_phi = a.phi.unwrap_or(0.0);
423            let b_phi = b.phi.unwrap_or(0.0);
424            a_phi
425                .partial_cmp(&b_phi)
426                .unwrap_or(std::cmp::Ordering::Equal)
427                .then_with(|| a.timestamp.cmp(&b.timestamp))
428        });
429        sorted
430            .iter()
431            .filter(|e| e.state != Some(ContextState::Pinned))
432            .take(self.entries.len() - keep_count)
433            .map(|e| e.path.clone())
434            .collect()
435    }
436
437    /// Global-Workspace ignition (#6): context items compete on salience (Phi);
438    /// any whose z-score exceeds the ignition threshold is "broadcast" — promoted
439    /// to Pinned so it survives eviction (`eviction_candidates_by_phi` already
440    /// skips Pinned) and pressure reinjection, and reaches the compiler's working
441    /// set as a pinned candidate. Deterministic: a pure threshold over the current
442    /// Phi distribution, no sampling. Returns the paths newly ignited this call.
443    pub fn ignite_high_salience(&mut self) -> Vec<String> {
444        let z_threshold = ignition_z_threshold();
445        let phis: Vec<f64> = self.entries.iter().filter_map(|e| e.phi).collect();
446        if phis.len() < GWT_MIN_ENTRIES {
447            return Vec::new();
448        }
449        let n = phis.len() as f64;
450        let mean = phis.iter().sum::<f64>() / n;
451        let var = phis.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n;
452        let std = var.sqrt();
453        if std <= f64::EPSILON {
454            return Vec::new();
455        }
456
457        let mut ignited = Vec::new();
458        for e in &mut self.entries {
459            let Some(phi) = e.phi else { continue };
460            let state = e.state.unwrap_or(ContextState::Included);
461            if matches!(state, ContextState::Excluded | ContextState::Pinned) {
462                continue;
463            }
464            if (phi - mean) / std > z_threshold {
465                e.state = Some(ContextState::Pinned);
466                ignited.push(e.path.clone());
467            }
468        }
469        if !ignited.is_empty() {
470            crate::core::introspect::tick("gwt_ignition");
471        }
472        ignited
473    }
474
475    /// Mark entries as stale if their source hash has changed.
476    pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
477        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path)
478            && let Some(ref old_hash) = entry.source_hash
479            && old_hash != new_hash
480        {
481            entry.state = Some(ContextState::Stale);
482            entry.source_hash = Some(new_hash.to_string());
483        }
484    }
485
486    pub fn pressure(&self) -> ContextPressure {
487        let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
488
489        let pinned_count = self
490            .entries
491            .iter()
492            .filter(|e| e.state == Some(ContextState::Pinned))
493            .count();
494        let stale_count = self
495            .entries
496            .iter()
497            .filter(|e| e.state == Some(ContextState::Stale))
498            .count();
499        let pinned_pressure = pinned_count as f64 * 0.02;
500        let stale_penalty = stale_count as f64 * 0.01;
501        let effective_utilization = (utilization + pinned_pressure + stale_penalty).min(1.0);
502
503        let effective_used = (effective_utilization * self.window_size as f64).round() as usize;
504        let remaining = self.window_size.saturating_sub(effective_used);
505
506        let recommendation = if effective_utilization > 0.9 {
507            PressureAction::EvictLeastRelevant
508        } else if effective_utilization > 0.75 {
509            PressureAction::ForceCompression
510        } else if effective_utilization > 0.5 {
511            PressureAction::SuggestCompression
512        } else {
513            PressureAction::NoAction
514        };
515
516        ContextPressure {
517            utilization: effective_utilization,
518            remaining_tokens: remaining,
519            entries_count: self.entries.len(),
520            recommendation,
521        }
522    }
523
524    pub fn compression_ratio(&self) -> f64 {
525        let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
526        if total_original == 0 {
527            return 1.0;
528        }
529        self.total_tokens_sent as f64 / total_original as f64
530    }
531
532    pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
533        let mut costs: Vec<(String, usize)> = self
534            .entries
535            .iter()
536            .map(|e| (e.path.clone(), e.sent_tokens))
537            .collect();
538        costs.sort_by_key(|b| std::cmp::Reverse(b.1));
539        costs
540    }
541
542    pub fn mode_distribution(&self) -> HashMap<String, usize> {
543        let mut dist: HashMap<String, usize> = HashMap::new();
544        for entry in &self.entries {
545            *dist.entry(entry.mode.clone()).or_insert(0) += 1;
546        }
547        dist
548    }
549
550    pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
551        if self.entries.len() <= keep_count {
552            return Vec::new();
553        }
554        let mut sorted = self.entries.clone();
555        sorted.sort_by_key(|e| e.timestamp);
556        sorted
557            .iter()
558            .take(self.entries.len() - keep_count)
559            .map(|e| e.path.clone())
560            .collect()
561    }
562
563    /// Remove one entry by target. Resolves partial paths and basenames
564    /// (#715); an ambiguous target removes nothing.
565    pub fn remove(&mut self, path: &str) -> bool {
566        match self.resolve_entry(path, None) {
567            LedgerResolution::Unique(idx) => {
568                self.remove_at(idx);
569                true
570            }
571            _ => false,
572        }
573    }
574
575    fn remove_at(&mut self, idx: usize) {
576        let entry = &self.entries[idx];
577        self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
578        self.total_tokens_saved = self
579            .total_tokens_saved
580            .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
581        self.entries.remove(idx);
582    }
583
584    /// Clear all entries and reset totals to zero.
585    pub fn reset(&mut self) {
586        let pinned_count = self
587            .entries
588            .iter()
589            .filter(|e| e.state == Some(ContextState::Pinned))
590            .count();
591        self.entries.clear();
592        self.total_tokens_sent = 0;
593        self.total_tokens_saved = 0;
594        if pinned_count > 0 {
595            tracing::info!("{pinned_count} pinned entries were also cleared");
596        }
597    }
598
599    /// Remove specific paths from the ledger. Returns count of entries removed.
600    /// Targets resolve like [`Self::resolve_entry`] (#715).
601    pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
602        self.evict_paths_resolved(paths, None)
603            .iter()
604            .filter(|o| o.resolved.is_some())
605            .count()
606    }
607
608    /// Eviction with full per-target diagnostics (#715): resolves each target
609    /// (exact → root-relative → unique suffix) and reports the canonical path
610    /// it removed, or the ambiguous candidates, so callers can surface WHY
611    /// nothing was evicted instead of a bare "Evicted 0/1".
612    pub fn evict_paths_resolved(
613        &mut self,
614        paths: &[&str],
615        project_root: Option<&str>,
616    ) -> Vec<EvictOutcome> {
617        paths
618            .iter()
619            .map(|target| match self.resolve_entry(target, project_root) {
620                LedgerResolution::Unique(idx) => {
621                    let resolved = self.entries[idx].path.clone();
622                    self.remove_at(idx);
623                    EvictOutcome {
624                        target: (*target).to_string(),
625                        resolved: Some(resolved),
626                        ambiguous: Vec::new(),
627                    }
628                }
629                LedgerResolution::Ambiguous(candidates) => EvictOutcome {
630                    target: (*target).to_string(),
631                    resolved: None,
632                    ambiguous: candidates,
633                },
634                LedgerResolution::NotFound => EvictOutcome {
635                    target: (*target).to_string(),
636                    resolved: None,
637                    ambiguous: Vec::new(),
638                },
639            })
640            .collect()
641    }
642
643    pub fn save(&self) {
644        self.save_for_agent("default");
645    }
646
647    /// Debounced save: only flushes to disk if >=3s since last save.
648    /// Reduces I/O overhead during burst sequences of tool calls.
649    pub fn save_debounced(&mut self) {
650        let now = std::time::Instant::now();
651        if let Some(last) = self.last_flush
652            && now.duration_since(last) < std::time::Duration::from_secs(3)
653        {
654            return;
655        }
656        self.save();
657        self.last_flush = Some(now);
658    }
659
660    pub fn save_for_agent(&self, agent_id: &str) {
661        if let Ok(path) = ledger_path(agent_id) {
662            if let Some(parent) = path.parent() {
663                let _ = std::fs::create_dir_all(parent);
664            }
665            let _lock = acquire_ledger_lock(&path);
666            if let Ok(json) = serde_json::to_string(self) {
667                atomic_write_json(&path, &json);
668            }
669        }
670    }
671
672    const MAX_LEDGER_ENTRIES: usize = 200;
673    const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
674
675    pub fn prune(&mut self) -> usize {
676        let before = self.entries.len();
677        let now = chrono::Utc::now().timestamp();
678
679        for entry in &mut self.entries {
680            if let Some(phi) = entry.phi {
681                let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
682                let decayed = phi * 0.95_f64.powf(hours_since);
683                entry.phi = Some(decayed.max(0.0));
684            }
685        }
686
687        self.entries
688            .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
689
690        self.entries.retain(|e| {
691            let age = now - e.timestamp;
692            let phi = e.phi.unwrap_or(0.0);
693            !(age > Self::STALE_AGE_SECS && phi < 0.1)
694        });
695
696        let mut seen = std::collections::HashSet::new();
697        self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
698        self.entries.retain(|e| {
699            // Lexical key only: entries were normalized when written, and the
700            // full variant would `realpath` every persisted path — the daemon
701            // runs this at boot (ContextLedger::load → prune) and stat-ing
702            // stored paths under ~/Documents from a launchd process pops the
703            // macOS TCC prompt (#356).
704            let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
705            seen.insert(key)
706        });
707
708        if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
709            self.entries.sort_by(|a, b| {
710                let pa = a.phi.unwrap_or(0.0);
711                let pb = b.phi.unwrap_or(0.0);
712                pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
713            });
714            self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
715        }
716
717        self.rebuild_totals();
718        before - self.entries.len()
719    }
720
721    fn rebuild_totals(&mut self) {
722        self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
723        self.total_tokens_saved = self
724            .entries
725            .iter()
726            .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
727            .sum();
728    }
729
730    pub fn load() -> Self {
731        Self::load_for_agent("default")
732    }
733
734    pub fn load_for_agent(agent_id: &str) -> Self {
735        let mut ledger: Self = ledger_path(agent_id)
736            .ok()
737            .and_then(|p| {
738                let _lock = acquire_ledger_lock(&p);
739                std::fs::read_to_string(p).ok()
740            })
741            .and_then(|s| serde_json::from_str(&s).ok())
742            .unwrap_or_default();
743        if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
744            ledger.window_size = window;
745        }
746        // #715 migration: older Windows builds persisted `\`-separated paths;
747        // resolution and dedup assume the forward-slash canonical form.
748        // Lexical only — no FS access on persisted paths (TCC, #356).
749        let mut migrated = false;
750        for entry in &mut ledger.entries {
751            let normalized = crate::core::pathutil::normalize_tool_path_lexical(&entry.path);
752            if normalized != entry.path {
753                entry.path = normalized;
754                migrated = true;
755            }
756        }
757        let pruned = ledger.prune();
758        if pruned > 0 || migrated {
759            ledger.save_for_agent(agent_id);
760        }
761        ledger
762    }
763
764    pub fn format_summary(&self) -> String {
765        let pressure = self.pressure();
766        format!(
767            "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
768            self.total_tokens_sent,
769            self.window_size,
770            pressure.utilization * 100.0,
771            self.entries.len(),
772            self.compression_ratio(),
773            pressure.recommendation,
774        )
775    }
776
777    pub fn adjusted_total_saved(&self) -> isize {
778        match crate::core::bounce_tracker::global().lock() {
779            Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
780            _ => self.total_tokens_saved as isize,
781        }
782    }
783}
784
785#[derive(Debug, Clone)]
786pub struct ReinjectionAction {
787    pub path: String,
788    pub current_mode: String,
789    pub new_mode: String,
790    pub tokens_freed: usize,
791}
792
793#[derive(Debug, Clone)]
794pub struct ReinjectionPlan {
795    pub actions: Vec<ReinjectionAction>,
796    pub total_tokens_freed: usize,
797    pub new_utilization: f64,
798}
799
800impl ContextLedger {
801    pub fn reinjection_plan(
802        &self,
803        intent: &super::intent_engine::StructuredIntent,
804        target_utilization: f64,
805    ) -> ReinjectionPlan {
806        let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
807        if current_util <= target_utilization {
808            return ReinjectionPlan {
809                actions: Vec::new(),
810                total_tokens_freed: 0,
811                new_utilization: current_util,
812            };
813        }
814
815        let tokens_to_free =
816            self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
817
818        let target_set: std::collections::HashSet<&str> = intent
819            .targets
820            .iter()
821            .map(std::string::String::as_str)
822            .collect();
823
824        let mut candidates: Vec<(usize, &LedgerEntry)> = self
825            .entries
826            .iter()
827            .enumerate()
828            .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
829            .collect();
830
831        candidates.sort_by(|a, b| {
832            let a_phi = a.1.phi.unwrap_or(0.0);
833            let b_phi = b.1.phi.unwrap_or(0.0);
834            a_phi
835                .partial_cmp(&b_phi)
836                .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
837        });
838
839        let mut actions = Vec::new();
840        let mut freed = 0usize;
841
842        for (_, entry) in &candidates {
843            if freed >= tokens_to_free {
844                break;
845            }
846            if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
847                let saving = entry.sent_tokens.saturating_sub(new_tokens);
848                if saving > 0 {
849                    actions.push(ReinjectionAction {
850                        path: entry.path.clone(),
851                        current_mode: entry.mode.clone(),
852                        new_mode,
853                        tokens_freed: saving,
854                    });
855                    freed += saving;
856                }
857            }
858        }
859
860        let new_sent = self.total_tokens_sent.saturating_sub(freed);
861        let new_utilization = new_sent as f64 / self.window_size as f64;
862
863        ReinjectionPlan {
864            actions,
865            total_tokens_freed: freed,
866            new_utilization,
867        }
868    }
869}
870
871fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
872    match current_mode {
873        "full" => Some(("signatures".to_string(), current_tokens / 5)),
874        "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
875        "signatures" => Some(("map".to_string(), current_tokens / 2)),
876        "map" => Some(("reference".to_string(), current_tokens / 4)),
877        _ => None,
878    }
879}
880
881/// Resolve the Global-Workspace ignition z-score threshold (#6): the
882/// `LEAN_CTX_GWT_IGNITION_Z` env override (must be > 0) wins, else the default
883/// [`GWT_IGNITION_Z`]. Deterministic for a given environment.
884fn ignition_z_threshold() -> f64 {
885    std::env::var("LEAN_CTX_GWT_IGNITION_Z")
886        .ok()
887        .and_then(|v| v.trim().parse::<f64>().ok())
888        .filter(|v| *v > 0.0)
889        .unwrap_or(GWT_IGNITION_Z)
890}
891
892impl Default for ContextLedger {
893    fn default() -> Self {
894        Self::new()
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    fn new_ledger_is_empty() {
904        let ledger = ContextLedger::new();
905        assert_eq!(ledger.total_tokens_sent, 0);
906        assert_eq!(ledger.entries.len(), 0);
907        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
908    }
909
910    #[test]
911    fn record_tracks_tokens() {
912        let mut ledger = ContextLedger::with_window_size(10000);
913        ledger.record("src/main.rs", "full", 500, 500);
914        ledger.record("src/lib.rs", "signatures", 1000, 200);
915        assert_eq!(ledger.total_tokens_sent, 700);
916        assert_eq!(ledger.total_tokens_saved, 800);
917        assert_eq!(ledger.entries.len(), 2);
918    }
919
920    #[test]
921    fn ignition_broadcasts_high_salience_outlier() {
922        // #6: an item far above the mean salience ignites and is pinned.
923        let mut ledger = ContextLedger::with_window_size(100_000);
924        for i in 0..5 {
925            ledger.record(&format!("low{i}.rs"), "map", 100, 100);
926        }
927        ledger.record("hot.rs", "full", 100, 100);
928        for e in &mut ledger.entries {
929            e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
930        }
931        let ignited = ledger.ignite_high_salience();
932        assert_eq!(ignited, vec!["hot.rs".to_string()]);
933        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
934        assert_eq!(hot.state, Some(ContextState::Pinned));
935    }
936
937    #[test]
938    fn ignition_skips_small_ledger() {
939        // Below GWT_MIN_ENTRIES the distribution is too small — no ignition.
940        let mut ledger = ContextLedger::with_window_size(100_000);
941        ledger.record("a.rs", "full", 100, 100);
942        ledger.entries[0].phi = Some(0.99);
943        assert!(ledger.ignite_high_salience().is_empty());
944    }
945
946    #[test]
947    fn ignition_is_deterministic() {
948        // Determinism contract (#498): same Phi distribution → same ignitions.
949        let build = || {
950            let mut l = ContextLedger::with_window_size(100_000);
951            for i in 0..5 {
952                l.record(&format!("f{i}.rs"), "map", 100, 100);
953            }
954            for (i, e) in l.entries.iter_mut().enumerate() {
955                e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
956            }
957            l
958        };
959        let mut a = build();
960        let mut b = build();
961        assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
962    }
963
964    #[test]
965    fn record_updates_existing_entry() {
966        let mut ledger = ContextLedger::with_window_size(10000);
967        ledger.record("src/main.rs", "full", 500, 500);
968        ledger.record("src/main.rs", "signatures", 500, 100);
969        assert_eq!(ledger.entries.len(), 1);
970        assert_eq!(ledger.total_tokens_sent, 100);
971        assert_eq!(ledger.total_tokens_saved, 400);
972    }
973
974    #[test]
975    fn access_count_tracks_rereads() {
976        let mut ledger = ContextLedger::with_window_size(10000);
977        ledger.record("src/main.rs", "full", 500, 500);
978        assert_eq!(ledger.entries[0].access_count, 1);
979        ledger.record("src/main.rs", "signatures", 500, 100);
980        ledger.record("src/main.rs", "map", 500, 50);
981        assert_eq!(ledger.entries[0].access_count, 3);
982        // A different file starts its own count.
983        ledger.record("src/other.rs", "full", 200, 200);
984        let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
985        assert_eq!(other.map(|e| e.access_count), Some(1));
986    }
987
988    #[test]
989    fn pressure_escalates() {
990        let mut ledger = ContextLedger::with_window_size(1000);
991        ledger.record("a.rs", "full", 600, 600);
992        assert_eq!(
993            ledger.pressure().recommendation,
994            PressureAction::SuggestCompression
995        );
996        ledger.record("b.rs", "full", 200, 200);
997        assert_eq!(
998            ledger.pressure().recommendation,
999            PressureAction::ForceCompression
1000        );
1001        ledger.record("c.rs", "full", 150, 150);
1002        assert_eq!(
1003            ledger.pressure().recommendation,
1004            PressureAction::EvictLeastRelevant
1005        );
1006    }
1007
1008    #[test]
1009    fn compression_ratio_accurate() {
1010        let mut ledger = ContextLedger::with_window_size(10000);
1011        ledger.record("a.rs", "full", 1000, 1000);
1012        ledger.record("b.rs", "signatures", 1000, 200);
1013        let ratio = ledger.compression_ratio();
1014        assert!((ratio - 0.6).abs() < 0.01);
1015    }
1016
1017    #[test]
1018    fn eviction_returns_oldest() {
1019        let mut ledger = ContextLedger::with_window_size(10000);
1020        ledger.record("old.rs", "full", 100, 100);
1021        std::thread::sleep(std::time::Duration::from_millis(10));
1022        ledger.record("new.rs", "full", 100, 100);
1023        let candidates = ledger.eviction_candidates(1);
1024        assert_eq!(candidates, vec!["old.rs"]);
1025    }
1026
1027    #[test]
1028    fn remove_updates_totals() {
1029        let mut ledger = ContextLedger::with_window_size(10000);
1030        ledger.record("a.rs", "full", 500, 500);
1031        ledger.record("b.rs", "full", 300, 300);
1032        assert!(ledger.remove("a.rs"));
1033        assert_eq!(ledger.total_tokens_sent, 300);
1034        assert_eq!(ledger.entries.len(), 1);
1035        assert!(!ledger.remove("nonexistent.rs"));
1036    }
1037
1038    #[test]
1039    fn reset_clears_everything() {
1040        let mut ledger = ContextLedger::with_window_size(10000);
1041        ledger.record("a.rs", "full", 500, 500);
1042        ledger.record("b.rs", "full", 300, 300);
1043        ledger.reset();
1044        assert_eq!(ledger.entries.len(), 0);
1045        assert_eq!(ledger.total_tokens_sent, 0);
1046        assert_eq!(ledger.total_tokens_saved, 0);
1047        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
1048    }
1049
1050    #[test]
1051    fn evict_paths_removes_matching() {
1052        let mut ledger = ContextLedger::with_window_size(10000);
1053        ledger.record("a.rs", "full", 500, 500);
1054        ledger.record("b.rs", "full", 300, 300);
1055        ledger.record("c.rs", "full", 200, 200);
1056        let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
1057        assert_eq!(removed, 2);
1058        assert_eq!(ledger.entries.len(), 1);
1059        assert_eq!(ledger.entries[0].path, "b.rs");
1060        assert_eq!(ledger.total_tokens_sent, 300);
1061    }
1062
1063    #[test]
1064    fn mode_distribution_counts() {
1065        let mut ledger = ContextLedger::new();
1066        ledger.record("a.rs", "full", 100, 100);
1067        ledger.record("b.rs", "signatures", 100, 50);
1068        ledger.record("c.rs", "full", 100, 100);
1069        let dist = ledger.mode_distribution();
1070        assert_eq!(dist.get("full"), Some(&2));
1071        assert_eq!(dist.get("signatures"), Some(&1));
1072    }
1073
1074    #[test]
1075    fn format_summary_includes_key_info() {
1076        let mut ledger = ContextLedger::with_window_size(10000);
1077        ledger.record("a.rs", "full", 500, 500);
1078        let summary = ledger.format_summary();
1079        assert!(summary.contains("500/10000"));
1080        assert!(summary.contains("1 files"));
1081    }
1082
1083    #[test]
1084    fn reinjection_no_action_when_low_pressure() {
1085        use crate::core::intent_engine::StructuredIntent;
1086
1087        let mut ledger = ContextLedger::with_window_size(10000);
1088        ledger.record("a.rs", "full", 100, 100);
1089        let intent = StructuredIntent::from_query("fix bug in a.rs");
1090        let plan = ledger.reinjection_plan(&intent, 0.7);
1091        assert!(plan.actions.is_empty());
1092        assert_eq!(plan.total_tokens_freed, 0);
1093    }
1094
1095    #[test]
1096    fn reinjection_downgrades_non_target_files() {
1097        use crate::core::intent_engine::StructuredIntent;
1098
1099        let mut ledger = ContextLedger::with_window_size(1000);
1100        ledger.record("src/target.rs", "full", 400, 400);
1101        std::thread::sleep(std::time::Duration::from_millis(10));
1102        ledger.record("src/other.rs", "full", 400, 400);
1103        std::thread::sleep(std::time::Duration::from_millis(10));
1104        ledger.record("src/utils.rs", "full", 200, 200);
1105
1106        let intent = StructuredIntent::from_query("fix bug in target.rs");
1107        let plan = ledger.reinjection_plan(&intent, 0.5);
1108
1109        assert!(!plan.actions.is_empty());
1110        assert!(
1111            plan.actions.iter().all(|a| !a.path.contains("target")),
1112            "should not downgrade target file"
1113        );
1114        assert!(plan.total_tokens_freed > 0);
1115    }
1116
1117    #[test]
1118    fn reinjection_preserves_targets() {
1119        use crate::core::intent_engine::StructuredIntent;
1120
1121        let mut ledger = ContextLedger::with_window_size(1000);
1122        ledger.record("src/auth.rs", "full", 900, 900);
1123        let intent = StructuredIntent::from_query("fix bug in auth.rs");
1124        let plan = ledger.reinjection_plan(&intent, 0.5);
1125        assert!(
1126            plan.actions.is_empty(),
1127            "should not downgrade target files even under pressure"
1128        );
1129    }
1130
1131    #[test]
1132    fn downgrade_mode_chain() {
1133        assert_eq!(
1134            downgrade_mode("full", 1000),
1135            Some(("signatures".to_string(), 200))
1136        );
1137        assert_eq!(
1138            downgrade_mode("signatures", 200),
1139            Some(("map".to_string(), 100))
1140        );
1141        assert_eq!(
1142            downgrade_mode("map", 100),
1143            Some(("reference".to_string(), 25))
1144        );
1145        assert_eq!(downgrade_mode("reference", 25), None);
1146    }
1147
1148    #[test]
1149    fn record_assigns_item_id() {
1150        let mut ledger = ContextLedger::new();
1151        ledger.record("src/main.rs", "full", 500, 500);
1152        let entry = &ledger.entries[0];
1153        assert!(entry.id.is_some());
1154        assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1155    }
1156
1157    #[test]
1158    fn record_sets_state_to_included() {
1159        let mut ledger = ContextLedger::new();
1160        ledger.record("src/main.rs", "full", 500, 500);
1161        assert_eq!(
1162            ledger.entries[0].state,
1163            Some(crate::core::context_field::ContextState::Included)
1164        );
1165    }
1166
1167    #[test]
1168    fn record_generates_view_costs() {
1169        let mut ledger = ContextLedger::new();
1170        ledger.record("src/main.rs", "full", 5000, 5000);
1171        let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1172        assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1173        assert_eq!(
1174            vc.get(&crate::core::context_field::ViewKind::Signatures),
1175            1000
1176        );
1177    }
1178
1179    #[test]
1180    fn update_phi_works() {
1181        let mut ledger = ContextLedger::new();
1182        ledger.record("a.rs", "full", 100, 100);
1183        ledger.update_phi("a.rs", 0.85);
1184        assert_eq!(ledger.entries[0].phi, Some(0.85));
1185    }
1186
1187    #[test]
1188    fn set_state_works() {
1189        let mut ledger = ContextLedger::new();
1190        ledger.record("a.rs", "full", 100, 100);
1191        ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1192        assert_eq!(
1193            ledger.entries[0].state,
1194            Some(crate::core::context_field::ContextState::Pinned)
1195        );
1196    }
1197
1198    #[test]
1199    fn items_by_state_filters() {
1200        let mut ledger = ContextLedger::new();
1201        ledger.record("a.rs", "full", 100, 100);
1202        ledger.record("b.rs", "full", 100, 100);
1203        ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1204        let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1205        assert_eq!(included.len(), 1);
1206        assert_eq!(included[0].path, "a.rs");
1207    }
1208
1209    #[test]
1210    fn eviction_by_phi_prefers_low_phi() {
1211        let mut ledger = ContextLedger::with_window_size(10000);
1212        ledger.record("high.rs", "full", 100, 100);
1213        ledger.update_phi("high.rs", 0.9);
1214        ledger.record("low.rs", "full", 100, 100);
1215        ledger.update_phi("low.rs", 0.1);
1216        let candidates = ledger.eviction_candidates_by_phi(1);
1217        assert_eq!(candidates, vec!["low.rs"]);
1218    }
1219
1220    #[test]
1221    fn eviction_by_phi_skips_pinned() {
1222        let mut ledger = ContextLedger::with_window_size(10000);
1223        ledger.record("pinned.rs", "full", 100, 100);
1224        ledger.update_phi("pinned.rs", 0.01);
1225        ledger.set_state(
1226            "pinned.rs",
1227            crate::core::context_field::ContextState::Pinned,
1228        );
1229        ledger.record("normal.rs", "full", 100, 100);
1230        ledger.update_phi("normal.rs", 0.5);
1231        let candidates = ledger.eviction_candidates_by_phi(1);
1232        assert_eq!(candidates, vec!["normal.rs"]);
1233    }
1234
1235    #[test]
1236    fn mark_stale_by_hash_detects_change() {
1237        let mut ledger = ContextLedger::new();
1238        ledger.record("a.rs", "full", 100, 100);
1239        ledger.entries[0].source_hash = Some("hash_v1".to_string());
1240        ledger.mark_stale_by_hash("a.rs", "hash_v2");
1241        assert_eq!(
1242            ledger.entries[0].state,
1243            Some(crate::core::context_field::ContextState::Stale)
1244        );
1245    }
1246
1247    #[test]
1248    fn find_by_id_works() {
1249        let mut ledger = ContextLedger::new();
1250        ledger.record("src/lib.rs", "full", 100, 100);
1251        let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1252        assert!(ledger.find_by_id(&id).is_some());
1253    }
1254
1255    #[test]
1256    fn phi_recomputed_on_reread_not_sticky() {
1257        // #2: Phi must track time-variant salience, not freeze on first read.
1258        let _env = crate::core::data_dir::test_env_lock();
1259        let dir = tempfile::tempdir().unwrap();
1260        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1261
1262        let mut ledger = ContextLedger::with_window_size(100_000);
1263        // First read carries a task whose keyword matches the path → relevance up.
1264        ledger.record_with_task(
1265            "src/authentication.rs",
1266            "full",
1267            2000,
1268            2000,
1269            Some("fix authentication login flow"),
1270        );
1271        let phi_with_task = ledger.entries[0].phi.unwrap();
1272        // Re-read with no task context → relevance collapses, so the blended Phi
1273        // must move. Before the fix this stayed frozen at the first value.
1274        ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1275        let phi_after = ledger.entries[0].phi.unwrap();
1276        assert_ne!(
1277            phi_with_task, phi_after,
1278            "Phi must be recomputed on re-read (#2)"
1279        );
1280        assert!(
1281            phi_after < phi_with_task,
1282            "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1283        );
1284    }
1285
1286    // ── #715: target resolution (exact → root-relative → unique suffix) ──
1287
1288    #[test]
1289    fn resolve_entry_matches_basename_suffix_uniquely() {
1290        let mut ledger = ContextLedger::with_window_size(10000);
1291        ledger.record("/home/user/proj/src/context_ledger.rs", "full", 500, 500);
1292        ledger.record("/home/user/proj/src/other.rs", "full", 300, 300);
1293
1294        // The exact repro from #715: basename against absolute entries.
1295        assert_eq!(
1296            ledger.resolve_entry("context_ledger.rs", None),
1297            LedgerResolution::Unique(0)
1298        );
1299        // Partial relative path.
1300        assert_eq!(
1301            ledger.resolve_entry("src/other.rs", None),
1302            LedgerResolution::Unique(1)
1303        );
1304        // Component boundary: `edger.rs` must NOT suffix-match `…ledger.rs`.
1305        assert_eq!(
1306            ledger.resolve_entry("edger.rs", None),
1307            LedgerResolution::NotFound
1308        );
1309    }
1310
1311    #[test]
1312    fn resolve_entry_reports_ambiguous_suffix() {
1313        let mut ledger = ContextLedger::with_window_size(10000);
1314        ledger.record("/proj/a/mod.rs", "full", 100, 100);
1315        ledger.record("/proj/b/mod.rs", "full", 100, 100);
1316
1317        match ledger.resolve_entry("mod.rs", None) {
1318            LedgerResolution::Ambiguous(candidates) => {
1319                assert_eq!(candidates.len(), 2);
1320                assert!(candidates.contains(&"/proj/a/mod.rs".to_string()));
1321            }
1322            other => panic!("expected Ambiguous, got {other:?}"),
1323        }
1324        // A longer suffix disambiguates.
1325        assert_eq!(
1326            ledger.resolve_entry("a/mod.rs", None),
1327            LedgerResolution::Unique(0)
1328        );
1329    }
1330
1331    #[test]
1332    fn resolve_entry_prefers_project_root_relative() {
1333        let mut ledger = ContextLedger::with_window_size(10000);
1334        ledger.record("/work/proj/src/lib.rs", "full", 100, 100);
1335        ledger.record("/elsewhere/src/lib.rs", "full", 100, 100);
1336
1337        // Suffix alone is ambiguous; the project root resolves it.
1338        assert!(matches!(
1339            ledger.resolve_entry("src/lib.rs", None),
1340            LedgerResolution::Ambiguous(_)
1341        ));
1342        assert_eq!(
1343            ledger.resolve_entry("src/lib.rs", Some("/work/proj")),
1344            LedgerResolution::Unique(0)
1345        );
1346    }
1347
1348    #[test]
1349    fn resolve_entry_handles_windows_separators() {
1350        let mut ledger = ContextLedger::with_window_size(10000);
1351        // Simulates a migrated ledger: forward-slash canonical entries.
1352        ledger.entries.push(LedgerEntry {
1353            path: "C:/Users/dev/proj/src/main.rs".to_string(),
1354            mode: "full".to_string(),
1355            original_tokens: 100,
1356            sent_tokens: 100,
1357            timestamp: chrono::Utc::now().timestamp(),
1358            id: None,
1359            kind: None,
1360            source_hash: None,
1361            state: None,
1362            phi: None,
1363            view_costs: None,
1364            active_view: None,
1365            provenance: None,
1366            access_count: 1,
1367        });
1368        ledger.total_tokens_sent = 100;
1369
1370        // Backslash target (Windows UI / dashboard) resolves lexically.
1371        assert_eq!(
1372            ledger.resolve_entry("src\\main.rs", None),
1373            LedgerResolution::Unique(0)
1374        );
1375        assert_eq!(ledger.evict_paths(&["src\\main.rs"]), 1);
1376        assert!(ledger.entries.is_empty());
1377    }
1378
1379    #[test]
1380    fn evict_paths_resolved_reports_outcomes() {
1381        let mut ledger = ContextLedger::with_window_size(10000);
1382        ledger.record("/proj/src/gate.rs", "full", 500, 500);
1383        ledger.record("/proj/a/dup.rs", "full", 100, 100);
1384        ledger.record("/proj/b/dup.rs", "full", 100, 100);
1385
1386        let outcomes =
1387            ledger.evict_paths_resolved(&["gate.rs", "dup.rs", "missing.rs"], Some("/proj"));
1388        assert_eq!(
1389            outcomes[0].resolved.as_deref(),
1390            Some("/proj/src/gate.rs"),
1391            "basename must resolve and evict"
1392        );
1393        assert!(outcomes[1].resolved.is_none());
1394        assert_eq!(outcomes[1].ambiguous.len(), 2, "ambiguity is diagnosed");
1395        assert!(outcomes[2].resolved.is_none());
1396        assert!(outcomes[2].ambiguous.is_empty());
1397        assert_eq!(ledger.entries.len(), 2, "only the unique match is removed");
1398        assert_eq!(ledger.total_tokens_sent, 200);
1399    }
1400
1401    #[test]
1402    fn set_state_resolves_partial_paths() {
1403        let mut ledger = ContextLedger::new();
1404        ledger.record("/proj/src/deep/file.rs", "full", 100, 100);
1405        ledger.set_state("file.rs", crate::core::context_field::ContextState::Pinned);
1406        assert_eq!(
1407            ledger.entries[0].state,
1408            Some(crate::core::context_field::ContextState::Pinned)
1409        );
1410    }
1411
1412    #[test]
1413    fn upsert_sets_source_hash_and_kind() {
1414        let mut ledger = ContextLedger::new();
1415        ledger.upsert(
1416            "src/main.rs",
1417            "full",
1418            500,
1419            500,
1420            Some("sha256_abc"),
1421            crate::core::context_field::ContextKind::File,
1422            None,
1423        );
1424        let entry = &ledger.entries[0];
1425        assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1426        assert_eq!(
1427            entry.kind,
1428            Some(crate::core::context_field::ContextKind::File)
1429        );
1430    }
1431}