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        // Pinned/stale entries reduce eviction flexibility, so they nudge
502        // pressure upward — but the nudge must stay bounded. Without a cap, a
503        // long session where many entries end up Pinned (e.g. via GWT
504        // ignition, #6) can alone drive utilization to 100% regardless of
505        // actual token usage.
506        const MAX_STATE_PRESSURE: f64 = 0.2;
507        let effective_utilization =
508            (utilization + (pinned_pressure + stale_penalty).min(MAX_STATE_PRESSURE)).min(1.0);
509
510        // `remaining_tokens` is a literal token-budget figure consumed by
511        // dashboards and the deficit-suggestion auto-loader
512        // (`context_deficit.rs`) as "how much room is actually left" — it
513        // must track real usage, not the heuristic-boosted
514        // `effective_utilization`, or a heavily-pinned/stale session reports
515        // 0 remaining (and silently starves auto-loading) while tokens of
516        // real headroom are still free.
517        let remaining = self.window_size.saturating_sub(self.total_tokens_sent);
518
519        let recommendation = if effective_utilization > 0.9 {
520            PressureAction::EvictLeastRelevant
521        } else if effective_utilization > 0.75 {
522            PressureAction::ForceCompression
523        } else if effective_utilization > 0.5 {
524            PressureAction::SuggestCompression
525        } else {
526            PressureAction::NoAction
527        };
528
529        ContextPressure {
530            utilization: effective_utilization,
531            remaining_tokens: remaining,
532            entries_count: self.entries.len(),
533            recommendation,
534        }
535    }
536
537    pub fn compression_ratio(&self) -> f64 {
538        let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
539        if total_original == 0 {
540            return 1.0;
541        }
542        self.total_tokens_sent as f64 / total_original as f64
543    }
544
545    pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
546        let mut costs: Vec<(String, usize)> = self
547            .entries
548            .iter()
549            .map(|e| (e.path.clone(), e.sent_tokens))
550            .collect();
551        costs.sort_by_key(|b| std::cmp::Reverse(b.1));
552        costs
553    }
554
555    pub fn mode_distribution(&self) -> HashMap<String, usize> {
556        let mut dist: HashMap<String, usize> = HashMap::new();
557        for entry in &self.entries {
558            *dist.entry(entry.mode.clone()).or_insert(0) += 1;
559        }
560        dist
561    }
562
563    pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
564        if self.entries.len() <= keep_count {
565            return Vec::new();
566        }
567        let mut sorted = self.entries.clone();
568        sorted.sort_by_key(|e| e.timestamp);
569        sorted
570            .iter()
571            .take(self.entries.len() - keep_count)
572            .map(|e| e.path.clone())
573            .collect()
574    }
575
576    /// Remove one entry by target. Resolves partial paths and basenames
577    /// (#715); an ambiguous target removes nothing.
578    pub fn remove(&mut self, path: &str) -> bool {
579        match self.resolve_entry(path, None) {
580            LedgerResolution::Unique(idx) => {
581                self.remove_at(idx);
582                true
583            }
584            _ => false,
585        }
586    }
587
588    fn remove_at(&mut self, idx: usize) {
589        let entry = &self.entries[idx];
590        self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
591        self.total_tokens_saved = self
592            .total_tokens_saved
593            .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
594        self.entries.remove(idx);
595    }
596
597    /// Clear all entries and reset totals to zero.
598    pub fn reset(&mut self) {
599        let pinned_count = self
600            .entries
601            .iter()
602            .filter(|e| e.state == Some(ContextState::Pinned))
603            .count();
604        self.entries.clear();
605        self.total_tokens_sent = 0;
606        self.total_tokens_saved = 0;
607        if pinned_count > 0 {
608            tracing::info!("{pinned_count} pinned entries were also cleared");
609        }
610    }
611
612    /// Remove specific paths from the ledger. Returns count of entries removed.
613    /// Targets resolve like [`Self::resolve_entry`] (#715).
614    pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
615        self.evict_paths_resolved(paths, None)
616            .iter()
617            .filter(|o| o.resolved.is_some())
618            .count()
619    }
620
621    /// Eviction with full per-target diagnostics (#715): resolves each target
622    /// (exact → root-relative → unique suffix) and reports the canonical path
623    /// it removed, or the ambiguous candidates, so callers can surface WHY
624    /// nothing was evicted instead of a bare "Evicted 0/1".
625    pub fn evict_paths_resolved(
626        &mut self,
627        paths: &[&str],
628        project_root: Option<&str>,
629    ) -> Vec<EvictOutcome> {
630        paths
631            .iter()
632            .map(|target| match self.resolve_entry(target, project_root) {
633                LedgerResolution::Unique(idx) => {
634                    let resolved = self.entries[idx].path.clone();
635                    self.remove_at(idx);
636                    EvictOutcome {
637                        target: (*target).to_string(),
638                        resolved: Some(resolved),
639                        ambiguous: Vec::new(),
640                    }
641                }
642                LedgerResolution::Ambiguous(candidates) => EvictOutcome {
643                    target: (*target).to_string(),
644                    resolved: None,
645                    ambiguous: candidates,
646                },
647                LedgerResolution::NotFound => EvictOutcome {
648                    target: (*target).to_string(),
649                    resolved: None,
650                    ambiguous: Vec::new(),
651                },
652            })
653            .collect()
654    }
655
656    pub fn save(&self) {
657        self.save_for_agent("default");
658    }
659
660    /// Debounced save: only flushes to disk if >=3s since last save.
661    /// Reduces I/O overhead during burst sequences of tool calls.
662    pub fn save_debounced(&mut self) {
663        let now = std::time::Instant::now();
664        if let Some(last) = self.last_flush
665            && now.duration_since(last) < std::time::Duration::from_secs(3)
666        {
667            return;
668        }
669        self.save();
670        self.last_flush = Some(now);
671    }
672
673    pub fn save_for_agent(&self, agent_id: &str) {
674        if let Ok(path) = ledger_path(agent_id) {
675            if let Some(parent) = path.parent() {
676                let _ = std::fs::create_dir_all(parent);
677            }
678            let _lock = acquire_ledger_lock(&path);
679            if let Ok(json) = serde_json::to_string(self) {
680                atomic_write_json(&path, &json);
681            }
682        }
683    }
684
685    const MAX_LEDGER_ENTRIES: usize = 200;
686    const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
687
688    pub fn prune(&mut self) -> usize {
689        let before = self.entries.len();
690        let now = chrono::Utc::now().timestamp();
691
692        for entry in &mut self.entries {
693            if let Some(phi) = entry.phi {
694                let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
695                let decayed = phi * 0.95_f64.powf(hours_since);
696                entry.phi = Some(decayed.max(0.0));
697            }
698        }
699
700        self.entries
701            .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
702
703        self.entries.retain(|e| {
704            let age = now - e.timestamp;
705            let phi = e.phi.unwrap_or(0.0);
706            !(age > Self::STALE_AGE_SECS && phi < 0.1)
707        });
708
709        let mut seen = std::collections::HashSet::new();
710        self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
711        self.entries.retain(|e| {
712            // Lexical key only: entries were normalized when written, and the
713            // full variant would `realpath` every persisted path — the daemon
714            // runs this at boot (ContextLedger::load → prune) and stat-ing
715            // stored paths under ~/Documents from a launchd process pops the
716            // macOS TCC prompt (#356).
717            let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
718            seen.insert(key)
719        });
720
721        if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
722            self.entries.sort_by(|a, b| {
723                let pa = a.phi.unwrap_or(0.0);
724                let pb = b.phi.unwrap_or(0.0);
725                pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
726            });
727            self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
728        }
729
730        self.rebuild_totals();
731        before - self.entries.len()
732    }
733
734    fn rebuild_totals(&mut self) {
735        self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
736        self.total_tokens_saved = self
737            .entries
738            .iter()
739            .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
740            .sum();
741    }
742
743    pub fn load() -> Self {
744        Self::load_for_agent("default")
745    }
746
747    pub fn load_for_agent(agent_id: &str) -> Self {
748        let mut ledger: Self = ledger_path(agent_id)
749            .ok()
750            .and_then(|p| {
751                let _lock = acquire_ledger_lock(&p);
752                std::fs::read_to_string(p).ok()
753            })
754            .and_then(|s| serde_json::from_str(&s).ok())
755            .unwrap_or_default();
756        if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
757            ledger.window_size = window;
758        }
759        // #715 migration: older Windows builds persisted `\`-separated paths;
760        // resolution and dedup assume the forward-slash canonical form.
761        // Lexical only — no FS access on persisted paths (TCC, #356).
762        let mut migrated = false;
763        for entry in &mut ledger.entries {
764            let normalized = crate::core::pathutil::normalize_tool_path_lexical(&entry.path);
765            if normalized != entry.path {
766                entry.path = normalized;
767                migrated = true;
768            }
769        }
770        let pruned = ledger.prune();
771        if pruned > 0 || migrated {
772            ledger.save_for_agent(agent_id);
773        }
774        ledger
775    }
776
777    pub fn format_summary(&self) -> String {
778        let pressure = self.pressure();
779        format!(
780            "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
781            self.total_tokens_sent,
782            self.window_size,
783            pressure.utilization * 100.0,
784            self.entries.len(),
785            self.compression_ratio(),
786            pressure.recommendation,
787        )
788    }
789
790    pub fn adjusted_total_saved(&self) -> isize {
791        match crate::core::bounce_tracker::global().lock() {
792            Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
793            _ => self.total_tokens_saved as isize,
794        }
795    }
796}
797
798#[derive(Debug, Clone)]
799pub struct ReinjectionAction {
800    pub path: String,
801    pub current_mode: String,
802    pub new_mode: String,
803    pub tokens_freed: usize,
804}
805
806#[derive(Debug, Clone)]
807pub struct ReinjectionPlan {
808    pub actions: Vec<ReinjectionAction>,
809    pub total_tokens_freed: usize,
810    pub new_utilization: f64,
811}
812
813impl ContextLedger {
814    pub fn reinjection_plan(
815        &self,
816        intent: &super::intent_engine::StructuredIntent,
817        target_utilization: f64,
818    ) -> ReinjectionPlan {
819        let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
820        if current_util <= target_utilization {
821            return ReinjectionPlan {
822                actions: Vec::new(),
823                total_tokens_freed: 0,
824                new_utilization: current_util,
825            };
826        }
827
828        let tokens_to_free =
829            self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
830
831        let target_set: std::collections::HashSet<&str> = intent
832            .targets
833            .iter()
834            .map(std::string::String::as_str)
835            .collect();
836
837        let mut candidates: Vec<(usize, &LedgerEntry)> = self
838            .entries
839            .iter()
840            .enumerate()
841            .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
842            .collect();
843
844        candidates.sort_by(|a, b| {
845            let a_phi = a.1.phi.unwrap_or(0.0);
846            let b_phi = b.1.phi.unwrap_or(0.0);
847            a_phi
848                .partial_cmp(&b_phi)
849                .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
850        });
851
852        let mut actions = Vec::new();
853        let mut freed = 0usize;
854
855        for (_, entry) in &candidates {
856            if freed >= tokens_to_free {
857                break;
858            }
859            if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
860                let saving = entry.sent_tokens.saturating_sub(new_tokens);
861                if saving > 0 {
862                    actions.push(ReinjectionAction {
863                        path: entry.path.clone(),
864                        current_mode: entry.mode.clone(),
865                        new_mode,
866                        tokens_freed: saving,
867                    });
868                    freed += saving;
869                }
870            }
871        }
872
873        let new_sent = self.total_tokens_sent.saturating_sub(freed);
874        let new_utilization = new_sent as f64 / self.window_size as f64;
875
876        ReinjectionPlan {
877            actions,
878            total_tokens_freed: freed,
879            new_utilization,
880        }
881    }
882}
883
884fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
885    match current_mode {
886        "full" => Some(("signatures".to_string(), current_tokens / 5)),
887        "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
888        "signatures" => Some(("map".to_string(), current_tokens / 2)),
889        "map" => Some(("reference".to_string(), current_tokens / 4)),
890        _ => None,
891    }
892}
893
894/// Resolve the Global-Workspace ignition z-score threshold (#6): the
895/// `LEAN_CTX_GWT_IGNITION_Z` env override (must be > 0) wins, else the default
896/// [`GWT_IGNITION_Z`]. Deterministic for a given environment.
897fn ignition_z_threshold() -> f64 {
898    std::env::var("LEAN_CTX_GWT_IGNITION_Z")
899        .ok()
900        .and_then(|v| v.trim().parse::<f64>().ok())
901        .filter(|v| *v > 0.0)
902        .unwrap_or(GWT_IGNITION_Z)
903}
904
905impl Default for ContextLedger {
906    fn default() -> Self {
907        Self::new()
908    }
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn new_ledger_is_empty() {
917        let ledger = ContextLedger::new();
918        assert_eq!(ledger.total_tokens_sent, 0);
919        assert_eq!(ledger.entries.len(), 0);
920        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
921    }
922
923    #[test]
924    fn record_tracks_tokens() {
925        let mut ledger = ContextLedger::with_window_size(10000);
926        ledger.record("src/main.rs", "full", 500, 500);
927        ledger.record("src/lib.rs", "signatures", 1000, 200);
928        assert_eq!(ledger.total_tokens_sent, 700);
929        assert_eq!(ledger.total_tokens_saved, 800);
930        assert_eq!(ledger.entries.len(), 2);
931    }
932
933    #[test]
934    fn ignition_broadcasts_high_salience_outlier() {
935        // #6: an item far above the mean salience ignites and is pinned.
936        let mut ledger = ContextLedger::with_window_size(100_000);
937        for i in 0..5 {
938            ledger.record(&format!("low{i}.rs"), "map", 100, 100);
939        }
940        ledger.record("hot.rs", "full", 100, 100);
941        for e in &mut ledger.entries {
942            e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
943        }
944        let ignited = ledger.ignite_high_salience();
945        assert_eq!(ignited, vec!["hot.rs".to_string()]);
946        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
947        assert_eq!(hot.state, Some(ContextState::Pinned));
948    }
949
950    #[test]
951    fn ignition_skips_small_ledger() {
952        // Below GWT_MIN_ENTRIES the distribution is too small — no ignition.
953        let mut ledger = ContextLedger::with_window_size(100_000);
954        ledger.record("a.rs", "full", 100, 100);
955        ledger.entries[0].phi = Some(0.99);
956        assert!(ledger.ignite_high_salience().is_empty());
957    }
958
959    #[test]
960    fn ignition_is_deterministic() {
961        // Determinism contract (#498): same Phi distribution → same ignitions.
962        let build = || {
963            let mut l = ContextLedger::with_window_size(100_000);
964            for i in 0..5 {
965                l.record(&format!("f{i}.rs"), "map", 100, 100);
966            }
967            for (i, e) in l.entries.iter_mut().enumerate() {
968                e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
969            }
970            l
971        };
972        let mut a = build();
973        let mut b = build();
974        assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
975    }
976
977    #[test]
978    fn record_updates_existing_entry() {
979        let mut ledger = ContextLedger::with_window_size(10000);
980        ledger.record("src/main.rs", "full", 500, 500);
981        ledger.record("src/main.rs", "signatures", 500, 100);
982        assert_eq!(ledger.entries.len(), 1);
983        assert_eq!(ledger.total_tokens_sent, 100);
984        assert_eq!(ledger.total_tokens_saved, 400);
985    }
986
987    #[test]
988    fn access_count_tracks_rereads() {
989        let mut ledger = ContextLedger::with_window_size(10000);
990        ledger.record("src/main.rs", "full", 500, 500);
991        assert_eq!(ledger.entries[0].access_count, 1);
992        ledger.record("src/main.rs", "signatures", 500, 100);
993        ledger.record("src/main.rs", "map", 500, 50);
994        assert_eq!(ledger.entries[0].access_count, 3);
995        // A different file starts its own count.
996        ledger.record("src/other.rs", "full", 200, 200);
997        let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
998        assert_eq!(other.map(|e| e.access_count), Some(1));
999    }
1000
1001    #[test]
1002    fn pressure_escalates() {
1003        let mut ledger = ContextLedger::with_window_size(1000);
1004        ledger.record("a.rs", "full", 600, 600);
1005        assert_eq!(
1006            ledger.pressure().recommendation,
1007            PressureAction::SuggestCompression
1008        );
1009        ledger.record("b.rs", "full", 200, 200);
1010        assert_eq!(
1011            ledger.pressure().recommendation,
1012            PressureAction::ForceCompression
1013        );
1014        ledger.record("c.rs", "full", 150, 150);
1015        assert_eq!(
1016            ledger.pressure().recommendation,
1017            PressureAction::EvictLeastRelevant
1018        );
1019    }
1020
1021    /// Regression: a session where many entries are Pinned (e.g. via GWT
1022    /// ignition over a long session) must not report `remaining_tokens: 0`
1023    /// nor 100% utilization when actual token usage is moderate. The pinned
1024    /// nudge must stay bounded, and `remaining_tokens` must track real usage.
1025    #[test]
1026    fn pinned_pressure_does_not_zero_out_remaining_tokens() {
1027        let mut ledger = ContextLedger::with_window_size(200_000);
1028        // ~59% raw utilization, matching the real-world repro.
1029        ledger.record("hot.rs", "full", 118_762, 118_762);
1030        for i in 0..32 {
1031            let path = format!("pinned_{i}.rs");
1032            ledger.record(&path, "full", 100, 100);
1033            ledger.set_state(&path, ContextState::Pinned);
1034        }
1035
1036        let pressure = ledger.pressure();
1037        assert!(
1038            pressure.utilization < 1.0,
1039            "32 pinned entries alone must not saturate utilization to 100%, got {}",
1040            pressure.utilization
1041        );
1042        assert!(
1043            pressure.remaining_tokens > 0,
1044            "real token headroom must not be reported as zero"
1045        );
1046        // total_tokens_sent = 118762 + 32*100 = 121962; window 200000.
1047        assert_eq!(pressure.remaining_tokens, 200_000 - 121_962);
1048    }
1049
1050    #[test]
1051    fn compression_ratio_accurate() {
1052        let mut ledger = ContextLedger::with_window_size(10000);
1053        ledger.record("a.rs", "full", 1000, 1000);
1054        ledger.record("b.rs", "signatures", 1000, 200);
1055        let ratio = ledger.compression_ratio();
1056        assert!((ratio - 0.6).abs() < 0.01);
1057    }
1058
1059    #[test]
1060    fn eviction_returns_oldest() {
1061        let mut ledger = ContextLedger::with_window_size(10000);
1062        ledger.record("old.rs", "full", 100, 100);
1063        std::thread::sleep(std::time::Duration::from_millis(10));
1064        ledger.record("new.rs", "full", 100, 100);
1065        let candidates = ledger.eviction_candidates(1);
1066        assert_eq!(candidates, vec!["old.rs"]);
1067    }
1068
1069    #[test]
1070    fn remove_updates_totals() {
1071        let mut ledger = ContextLedger::with_window_size(10000);
1072        ledger.record("a.rs", "full", 500, 500);
1073        ledger.record("b.rs", "full", 300, 300);
1074        assert!(ledger.remove("a.rs"));
1075        assert_eq!(ledger.total_tokens_sent, 300);
1076        assert_eq!(ledger.entries.len(), 1);
1077        assert!(!ledger.remove("nonexistent.rs"));
1078    }
1079
1080    #[test]
1081    fn reset_clears_everything() {
1082        let mut ledger = ContextLedger::with_window_size(10000);
1083        ledger.record("a.rs", "full", 500, 500);
1084        ledger.record("b.rs", "full", 300, 300);
1085        ledger.reset();
1086        assert_eq!(ledger.entries.len(), 0);
1087        assert_eq!(ledger.total_tokens_sent, 0);
1088        assert_eq!(ledger.total_tokens_saved, 0);
1089        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
1090    }
1091
1092    #[test]
1093    fn evict_paths_removes_matching() {
1094        let mut ledger = ContextLedger::with_window_size(10000);
1095        ledger.record("a.rs", "full", 500, 500);
1096        ledger.record("b.rs", "full", 300, 300);
1097        ledger.record("c.rs", "full", 200, 200);
1098        let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
1099        assert_eq!(removed, 2);
1100        assert_eq!(ledger.entries.len(), 1);
1101        assert_eq!(ledger.entries[0].path, "b.rs");
1102        assert_eq!(ledger.total_tokens_sent, 300);
1103    }
1104
1105    #[test]
1106    fn mode_distribution_counts() {
1107        let mut ledger = ContextLedger::new();
1108        ledger.record("a.rs", "full", 100, 100);
1109        ledger.record("b.rs", "signatures", 100, 50);
1110        ledger.record("c.rs", "full", 100, 100);
1111        let dist = ledger.mode_distribution();
1112        assert_eq!(dist.get("full"), Some(&2));
1113        assert_eq!(dist.get("signatures"), Some(&1));
1114    }
1115
1116    #[test]
1117    fn format_summary_includes_key_info() {
1118        let mut ledger = ContextLedger::with_window_size(10000);
1119        ledger.record("a.rs", "full", 500, 500);
1120        let summary = ledger.format_summary();
1121        assert!(summary.contains("500/10000"));
1122        assert!(summary.contains("1 files"));
1123    }
1124
1125    #[test]
1126    fn reinjection_no_action_when_low_pressure() {
1127        use crate::core::intent_engine::StructuredIntent;
1128
1129        let mut ledger = ContextLedger::with_window_size(10000);
1130        ledger.record("a.rs", "full", 100, 100);
1131        let intent = StructuredIntent::from_query("fix bug in a.rs");
1132        let plan = ledger.reinjection_plan(&intent, 0.7);
1133        assert!(plan.actions.is_empty());
1134        assert_eq!(plan.total_tokens_freed, 0);
1135    }
1136
1137    #[test]
1138    fn reinjection_downgrades_non_target_files() {
1139        use crate::core::intent_engine::StructuredIntent;
1140
1141        let mut ledger = ContextLedger::with_window_size(1000);
1142        ledger.record("src/target.rs", "full", 400, 400);
1143        std::thread::sleep(std::time::Duration::from_millis(10));
1144        ledger.record("src/other.rs", "full", 400, 400);
1145        std::thread::sleep(std::time::Duration::from_millis(10));
1146        ledger.record("src/utils.rs", "full", 200, 200);
1147
1148        let intent = StructuredIntent::from_query("fix bug in target.rs");
1149        let plan = ledger.reinjection_plan(&intent, 0.5);
1150
1151        assert!(!plan.actions.is_empty());
1152        assert!(
1153            plan.actions.iter().all(|a| !a.path.contains("target")),
1154            "should not downgrade target file"
1155        );
1156        assert!(plan.total_tokens_freed > 0);
1157    }
1158
1159    #[test]
1160    fn reinjection_preserves_targets() {
1161        use crate::core::intent_engine::StructuredIntent;
1162
1163        let mut ledger = ContextLedger::with_window_size(1000);
1164        ledger.record("src/auth.rs", "full", 900, 900);
1165        let intent = StructuredIntent::from_query("fix bug in auth.rs");
1166        let plan = ledger.reinjection_plan(&intent, 0.5);
1167        assert!(
1168            plan.actions.is_empty(),
1169            "should not downgrade target files even under pressure"
1170        );
1171    }
1172
1173    #[test]
1174    fn downgrade_mode_chain() {
1175        assert_eq!(
1176            downgrade_mode("full", 1000),
1177            Some(("signatures".to_string(), 200))
1178        );
1179        assert_eq!(
1180            downgrade_mode("signatures", 200),
1181            Some(("map".to_string(), 100))
1182        );
1183        assert_eq!(
1184            downgrade_mode("map", 100),
1185            Some(("reference".to_string(), 25))
1186        );
1187        assert_eq!(downgrade_mode("reference", 25), None);
1188    }
1189
1190    #[test]
1191    fn record_assigns_item_id() {
1192        let mut ledger = ContextLedger::new();
1193        ledger.record("src/main.rs", "full", 500, 500);
1194        let entry = &ledger.entries[0];
1195        assert!(entry.id.is_some());
1196        assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1197    }
1198
1199    #[test]
1200    fn record_sets_state_to_included() {
1201        let mut ledger = ContextLedger::new();
1202        ledger.record("src/main.rs", "full", 500, 500);
1203        assert_eq!(
1204            ledger.entries[0].state,
1205            Some(crate::core::context_field::ContextState::Included)
1206        );
1207    }
1208
1209    #[test]
1210    fn record_generates_view_costs() {
1211        let mut ledger = ContextLedger::new();
1212        ledger.record("src/main.rs", "full", 5000, 5000);
1213        let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1214        assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1215        assert_eq!(
1216            vc.get(&crate::core::context_field::ViewKind::Signatures),
1217            1000
1218        );
1219    }
1220
1221    #[test]
1222    fn update_phi_works() {
1223        let mut ledger = ContextLedger::new();
1224        ledger.record("a.rs", "full", 100, 100);
1225        ledger.update_phi("a.rs", 0.85);
1226        assert_eq!(ledger.entries[0].phi, Some(0.85));
1227    }
1228
1229    #[test]
1230    fn set_state_works() {
1231        let mut ledger = ContextLedger::new();
1232        ledger.record("a.rs", "full", 100, 100);
1233        ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1234        assert_eq!(
1235            ledger.entries[0].state,
1236            Some(crate::core::context_field::ContextState::Pinned)
1237        );
1238    }
1239
1240    #[test]
1241    fn items_by_state_filters() {
1242        let mut ledger = ContextLedger::new();
1243        ledger.record("a.rs", "full", 100, 100);
1244        ledger.record("b.rs", "full", 100, 100);
1245        ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1246        let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1247        assert_eq!(included.len(), 1);
1248        assert_eq!(included[0].path, "a.rs");
1249    }
1250
1251    #[test]
1252    fn eviction_by_phi_prefers_low_phi() {
1253        let mut ledger = ContextLedger::with_window_size(10000);
1254        ledger.record("high.rs", "full", 100, 100);
1255        ledger.update_phi("high.rs", 0.9);
1256        ledger.record("low.rs", "full", 100, 100);
1257        ledger.update_phi("low.rs", 0.1);
1258        let candidates = ledger.eviction_candidates_by_phi(1);
1259        assert_eq!(candidates, vec!["low.rs"]);
1260    }
1261
1262    #[test]
1263    fn eviction_by_phi_skips_pinned() {
1264        let mut ledger = ContextLedger::with_window_size(10000);
1265        ledger.record("pinned.rs", "full", 100, 100);
1266        ledger.update_phi("pinned.rs", 0.01);
1267        ledger.set_state(
1268            "pinned.rs",
1269            crate::core::context_field::ContextState::Pinned,
1270        );
1271        ledger.record("normal.rs", "full", 100, 100);
1272        ledger.update_phi("normal.rs", 0.5);
1273        let candidates = ledger.eviction_candidates_by_phi(1);
1274        assert_eq!(candidates, vec!["normal.rs"]);
1275    }
1276
1277    #[test]
1278    fn mark_stale_by_hash_detects_change() {
1279        let mut ledger = ContextLedger::new();
1280        ledger.record("a.rs", "full", 100, 100);
1281        ledger.entries[0].source_hash = Some("hash_v1".to_string());
1282        ledger.mark_stale_by_hash("a.rs", "hash_v2");
1283        assert_eq!(
1284            ledger.entries[0].state,
1285            Some(crate::core::context_field::ContextState::Stale)
1286        );
1287    }
1288
1289    #[test]
1290    fn find_by_id_works() {
1291        let mut ledger = ContextLedger::new();
1292        ledger.record("src/lib.rs", "full", 100, 100);
1293        let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1294        assert!(ledger.find_by_id(&id).is_some());
1295    }
1296
1297    #[test]
1298    fn phi_recomputed_on_reread_not_sticky() {
1299        // #2: Phi must track time-variant salience, not freeze on first read.
1300        let _env = crate::core::data_dir::test_env_lock();
1301        let dir = tempfile::tempdir().unwrap();
1302        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1303
1304        let mut ledger = ContextLedger::with_window_size(100_000);
1305        // First read carries a task whose keyword matches the path → relevance up.
1306        ledger.record_with_task(
1307            "src/authentication.rs",
1308            "full",
1309            2000,
1310            2000,
1311            Some("fix authentication login flow"),
1312        );
1313        let phi_with_task = ledger.entries[0].phi.unwrap();
1314        // Re-read with no task context → relevance collapses, so the blended Phi
1315        // must move. Before the fix this stayed frozen at the first value.
1316        ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1317        let phi_after = ledger.entries[0].phi.unwrap();
1318        assert_ne!(
1319            phi_with_task, phi_after,
1320            "Phi must be recomputed on re-read (#2)"
1321        );
1322        assert!(
1323            phi_after < phi_with_task,
1324            "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1325        );
1326    }
1327
1328    // ── #715: target resolution (exact → root-relative → unique suffix) ──
1329
1330    #[test]
1331    fn resolve_entry_matches_basename_suffix_uniquely() {
1332        let mut ledger = ContextLedger::with_window_size(10000);
1333        ledger.record("/home/user/proj/src/context_ledger.rs", "full", 500, 500);
1334        ledger.record("/home/user/proj/src/other.rs", "full", 300, 300);
1335
1336        // The exact repro from #715: basename against absolute entries.
1337        assert_eq!(
1338            ledger.resolve_entry("context_ledger.rs", None),
1339            LedgerResolution::Unique(0)
1340        );
1341        // Partial relative path.
1342        assert_eq!(
1343            ledger.resolve_entry("src/other.rs", None),
1344            LedgerResolution::Unique(1)
1345        );
1346        // Component boundary: `edger.rs` must NOT suffix-match `…ledger.rs`.
1347        assert_eq!(
1348            ledger.resolve_entry("edger.rs", None),
1349            LedgerResolution::NotFound
1350        );
1351    }
1352
1353    #[test]
1354    fn resolve_entry_reports_ambiguous_suffix() {
1355        let mut ledger = ContextLedger::with_window_size(10000);
1356        ledger.record("/proj/a/mod.rs", "full", 100, 100);
1357        ledger.record("/proj/b/mod.rs", "full", 100, 100);
1358
1359        match ledger.resolve_entry("mod.rs", None) {
1360            LedgerResolution::Ambiguous(candidates) => {
1361                assert_eq!(candidates.len(), 2);
1362                assert!(candidates.contains(&"/proj/a/mod.rs".to_string()));
1363            }
1364            other => panic!("expected Ambiguous, got {other:?}"),
1365        }
1366        // A longer suffix disambiguates.
1367        assert_eq!(
1368            ledger.resolve_entry("a/mod.rs", None),
1369            LedgerResolution::Unique(0)
1370        );
1371    }
1372
1373    #[test]
1374    fn resolve_entry_prefers_project_root_relative() {
1375        let mut ledger = ContextLedger::with_window_size(10000);
1376        ledger.record("/work/proj/src/lib.rs", "full", 100, 100);
1377        ledger.record("/elsewhere/src/lib.rs", "full", 100, 100);
1378
1379        // Suffix alone is ambiguous; the project root resolves it.
1380        assert!(matches!(
1381            ledger.resolve_entry("src/lib.rs", None),
1382            LedgerResolution::Ambiguous(_)
1383        ));
1384        assert_eq!(
1385            ledger.resolve_entry("src/lib.rs", Some("/work/proj")),
1386            LedgerResolution::Unique(0)
1387        );
1388    }
1389
1390    #[test]
1391    fn resolve_entry_handles_windows_separators() {
1392        let mut ledger = ContextLedger::with_window_size(10000);
1393        // Simulates a migrated ledger: forward-slash canonical entries.
1394        ledger.entries.push(LedgerEntry {
1395            path: "C:/Users/dev/proj/src/main.rs".to_string(),
1396            mode: "full".to_string(),
1397            original_tokens: 100,
1398            sent_tokens: 100,
1399            timestamp: chrono::Utc::now().timestamp(),
1400            id: None,
1401            kind: None,
1402            source_hash: None,
1403            state: None,
1404            phi: None,
1405            view_costs: None,
1406            active_view: None,
1407            provenance: None,
1408            access_count: 1,
1409        });
1410        ledger.total_tokens_sent = 100;
1411
1412        // Backslash target (Windows UI / dashboard) resolves lexically.
1413        assert_eq!(
1414            ledger.resolve_entry("src\\main.rs", None),
1415            LedgerResolution::Unique(0)
1416        );
1417        assert_eq!(ledger.evict_paths(&["src\\main.rs"]), 1);
1418        assert!(ledger.entries.is_empty());
1419    }
1420
1421    #[test]
1422    fn evict_paths_resolved_reports_outcomes() {
1423        let mut ledger = ContextLedger::with_window_size(10000);
1424        ledger.record("/proj/src/gate.rs", "full", 500, 500);
1425        ledger.record("/proj/a/dup.rs", "full", 100, 100);
1426        ledger.record("/proj/b/dup.rs", "full", 100, 100);
1427
1428        let outcomes =
1429            ledger.evict_paths_resolved(&["gate.rs", "dup.rs", "missing.rs"], Some("/proj"));
1430        assert_eq!(
1431            outcomes[0].resolved.as_deref(),
1432            Some("/proj/src/gate.rs"),
1433            "basename must resolve and evict"
1434        );
1435        assert!(outcomes[1].resolved.is_none());
1436        assert_eq!(outcomes[1].ambiguous.len(), 2, "ambiguity is diagnosed");
1437        assert!(outcomes[2].resolved.is_none());
1438        assert!(outcomes[2].ambiguous.is_empty());
1439        assert_eq!(ledger.entries.len(), 2, "only the unique match is removed");
1440        assert_eq!(ledger.total_tokens_sent, 200);
1441    }
1442
1443    #[test]
1444    fn set_state_resolves_partial_paths() {
1445        let mut ledger = ContextLedger::new();
1446        ledger.record("/proj/src/deep/file.rs", "full", 100, 100);
1447        ledger.set_state("file.rs", crate::core::context_field::ContextState::Pinned);
1448        assert_eq!(
1449            ledger.entries[0].state,
1450            Some(crate::core::context_field::ContextState::Pinned)
1451        );
1452    }
1453
1454    #[test]
1455    fn upsert_sets_source_hash_and_kind() {
1456        let mut ledger = ContextLedger::new();
1457        ledger.upsert(
1458            "src/main.rs",
1459            "full",
1460            500,
1461            500,
1462            Some("sha256_abc"),
1463            crate::core::context_field::ContextKind::File,
1464            None,
1465        );
1466        let entry = &ledger.entries[0];
1467        assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1468        assert_eq!(
1469            entry.kind,
1470            Some(crate::core::context_field::ContextKind::File)
1471        );
1472    }
1473}