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
145impl ContextLedger {
146    pub fn new() -> Self {
147        Self {
148            window_size: DEFAULT_CONTEXT_WINDOW,
149            entries: Vec::new(),
150            total_tokens_sent: 0,
151            total_tokens_saved: 0,
152            last_flush: None,
153        }
154    }
155
156    pub fn with_window_size(size: usize) -> Self {
157        Self {
158            window_size: size,
159            entries: Vec::new(),
160            total_tokens_sent: 0,
161            total_tokens_saved: 0,
162            last_flush: None,
163        }
164    }
165
166    pub fn record(&mut self, path: &str, mode: &str, original_tokens: usize, sent_tokens: usize) {
167        self.record_with_task(path, mode, original_tokens, sent_tokens, None);
168    }
169
170    pub fn record_with_task(
171        &mut self,
172        path: &str,
173        mode: &str,
174        original_tokens: usize,
175        sent_tokens: usize,
176        task: Option<&str>,
177    ) {
178        let path = crate::core::pathutil::normalize_tool_path(path);
179        let item_id = ContextItemId::from_file(&path);
180
181        let phi =
182            Self::compute_real_phi(&path, sent_tokens, original_tokens, self.window_size, task);
183
184        if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) {
185            self.total_tokens_sent -= existing.sent_tokens;
186            self.total_tokens_saved -= existing
187                .original_tokens
188                .saturating_sub(existing.sent_tokens);
189            existing.mode = mode.to_string();
190            existing.original_tokens = original_tokens;
191            existing.sent_tokens = sent_tokens;
192            existing.timestamp = chrono::Utc::now().timestamp();
193            existing.access_count = existing.access_count.saturating_add(1);
194            existing.active_view = Some(ViewKind::parse(mode));
195            if existing.id.is_none() {
196                existing.id = Some(item_id);
197            }
198            if existing.state.is_none() || existing.state == Some(ContextState::Candidate) {
199                existing.state = Some(ContextState::Included);
200            }
201            // #2 Sticky-Phi fix: salience is time-variant (recency, task match,
202            // access frequency all changed since the first read), so recompute
203            // Phi on every re-read instead of freezing the first value. Blend
204            // with the prior score via a fixed-alpha EMA — deterministic, and
205            // damped so a single noisy read can't whipsaw eviction order.
206            existing.phi = Some(match existing.phi {
207                Some(old) => PHI_REREAD_ALPHA * phi + (1.0 - PHI_REREAD_ALPHA) * old,
208                None => phi,
209            });
210            crate::core::introspect::tick("phi_recompute");
211        } else {
212            self.entries.push(LedgerEntry {
213                path: path.clone(),
214                mode: mode.to_string(),
215                original_tokens,
216                sent_tokens,
217                timestamp: chrono::Utc::now().timestamp(),
218                id: Some(item_id),
219                kind: Some(ContextKind::File),
220                source_hash: None,
221                state: Some(ContextState::Included),
222                phi: Some(phi),
223                view_costs: Some(ViewCosts::from_full_tokens(original_tokens)),
224                active_view: Some(ViewKind::parse(mode)),
225                provenance: None,
226                access_count: 1,
227            });
228        }
229        self.total_tokens_sent += sent_tokens;
230        self.total_tokens_saved += original_tokens.saturating_sub(sent_tokens);
231    }
232
233    fn compute_real_phi(
234        path: &str,
235        sent_tokens: usize,
236        original_tokens: usize,
237        window_size: usize,
238        task: Option<&str>,
239    ) -> f64 {
240        use crate::core::context_field::{ContextField, compute_signals_for_path};
241
242        let (signals, _costs) =
243            compute_signals_for_path(path, task, None, window_size, original_tokens);
244        // #4: use the learned (bandit-selected) field weights when available.
245        let phi = ContextField::active().compute_phi(&signals);
246        if phi > 0.0 {
247            return phi;
248        }
249
250        Self::compute_lightweight_phi(sent_tokens, window_size)
251    }
252
253    fn compute_lightweight_phi(sent_tokens: usize, window_size: usize) -> f64 {
254        use crate::core::context_field::{ContextField, FieldSignals};
255        let token_cost_norm = if window_size > 0 {
256            (sent_tokens as f64 / window_size as f64).min(1.0)
257        } else {
258            0.0
259        };
260        let signals = FieldSignals {
261            relevance: 1.0,
262            surprise: 0.5,
263            graph_proximity: 0.0,
264            history_signal: 0.0,
265            token_cost_norm,
266            redundancy: 0.0,
267        };
268        ContextField::active().compute_phi(&signals)
269    }
270
271    /// Record with full CFT metadata including source hash and provenance.
272    pub fn upsert(
273        &mut self,
274        path: &str,
275        mode: &str,
276        original_tokens: usize,
277        sent_tokens: usize,
278        source_hash: Option<&str>,
279        kind: ContextKind,
280        provenance: Option<Provenance>,
281    ) {
282        self.record(path, mode, original_tokens, sent_tokens);
283        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
284            entry.kind = Some(kind);
285            if let Some(h) = source_hash
286                && entry.source_hash.as_deref() != Some(h)
287            {
288                if entry.source_hash.is_some() {
289                    entry.state = Some(ContextState::Stale);
290                }
291                entry.source_hash = Some(h.to_string());
292            }
293            if let Some(prov) = provenance {
294                entry.provenance = Some(prov);
295            }
296        }
297    }
298
299    /// Update the Phi score for an entry.
300    pub fn update_phi(&mut self, path: &str, phi: f64) {
301        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
302            entry.phi = Some(phi);
303        }
304    }
305
306    /// Set the state for an entry.
307    pub fn set_state(&mut self, path: &str, state: ContextState) {
308        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
309            entry.state = Some(state);
310        }
311    }
312
313    /// Find an entry by its ContextItemId.
314    pub fn find_by_id(&self, id: &ContextItemId) -> Option<&LedgerEntry> {
315        self.entries.iter().find(|e| e.id.as_ref() == Some(id))
316    }
317
318    /// Get all entries with a specific state.
319    pub fn items_by_state(&self, state: ContextState) -> Vec<&LedgerEntry> {
320        self.entries
321            .iter()
322            .filter(|e| e.state == Some(state))
323            .collect()
324    }
325
326    /// Eviction candidates ordered by Phi (lowest first), falling back to
327    /// timestamp for entries without Phi scores.
328    pub fn eviction_candidates_by_phi(&self, keep_count: usize) -> Vec<String> {
329        if self.entries.len() <= keep_count {
330            return Vec::new();
331        }
332        let mut sorted = self.entries.clone();
333        sorted.sort_by(|a, b| {
334            let a_phi = a.phi.unwrap_or(0.0);
335            let b_phi = b.phi.unwrap_or(0.0);
336            a_phi
337                .partial_cmp(&b_phi)
338                .unwrap_or(std::cmp::Ordering::Equal)
339                .then_with(|| a.timestamp.cmp(&b.timestamp))
340        });
341        sorted
342            .iter()
343            .filter(|e| e.state != Some(ContextState::Pinned))
344            .take(self.entries.len() - keep_count)
345            .map(|e| e.path.clone())
346            .collect()
347    }
348
349    /// Global-Workspace ignition (#6): context items compete on salience (Phi);
350    /// any whose z-score exceeds the ignition threshold is "broadcast" — promoted
351    /// to Pinned so it survives eviction (`eviction_candidates_by_phi` already
352    /// skips Pinned) and pressure reinjection, and reaches the compiler's working
353    /// set as a pinned candidate. Deterministic: a pure threshold over the current
354    /// Phi distribution, no sampling. Returns the paths newly ignited this call.
355    pub fn ignite_high_salience(&mut self) -> Vec<String> {
356        let z_threshold = ignition_z_threshold();
357        let phis: Vec<f64> = self.entries.iter().filter_map(|e| e.phi).collect();
358        if phis.len() < GWT_MIN_ENTRIES {
359            return Vec::new();
360        }
361        let n = phis.len() as f64;
362        let mean = phis.iter().sum::<f64>() / n;
363        let var = phis.iter().map(|p| (p - mean).powi(2)).sum::<f64>() / n;
364        let std = var.sqrt();
365        if std <= f64::EPSILON {
366            return Vec::new();
367        }
368
369        let mut ignited = Vec::new();
370        for e in &mut self.entries {
371            let Some(phi) = e.phi else { continue };
372            let state = e.state.unwrap_or(ContextState::Included);
373            if matches!(state, ContextState::Excluded | ContextState::Pinned) {
374                continue;
375            }
376            if (phi - mean) / std > z_threshold {
377                e.state = Some(ContextState::Pinned);
378                ignited.push(e.path.clone());
379            }
380        }
381        if !ignited.is_empty() {
382            crate::core::introspect::tick("gwt_ignition");
383        }
384        ignited
385    }
386
387    /// Mark entries as stale if their source hash has changed.
388    pub fn mark_stale_by_hash(&mut self, path: &str, new_hash: &str) {
389        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path)
390            && let Some(ref old_hash) = entry.source_hash
391            && old_hash != new_hash
392        {
393            entry.state = Some(ContextState::Stale);
394            entry.source_hash = Some(new_hash.to_string());
395        }
396    }
397
398    pub fn pressure(&self) -> ContextPressure {
399        let utilization = self.total_tokens_sent as f64 / self.window_size as f64;
400
401        let pinned_count = self
402            .entries
403            .iter()
404            .filter(|e| e.state == Some(ContextState::Pinned))
405            .count();
406        let stale_count = self
407            .entries
408            .iter()
409            .filter(|e| e.state == Some(ContextState::Stale))
410            .count();
411        let pinned_pressure = pinned_count as f64 * 0.02;
412        let stale_penalty = stale_count as f64 * 0.01;
413        let effective_utilization = (utilization + pinned_pressure + stale_penalty).min(1.0);
414
415        let effective_used = (effective_utilization * self.window_size as f64).round() as usize;
416        let remaining = self.window_size.saturating_sub(effective_used);
417
418        let recommendation = if effective_utilization > 0.9 {
419            PressureAction::EvictLeastRelevant
420        } else if effective_utilization > 0.75 {
421            PressureAction::ForceCompression
422        } else if effective_utilization > 0.5 {
423            PressureAction::SuggestCompression
424        } else {
425            PressureAction::NoAction
426        };
427
428        ContextPressure {
429            utilization: effective_utilization,
430            remaining_tokens: remaining,
431            entries_count: self.entries.len(),
432            recommendation,
433        }
434    }
435
436    pub fn compression_ratio(&self) -> f64 {
437        let total_original: usize = self.entries.iter().map(|e| e.original_tokens).sum();
438        if total_original == 0 {
439            return 1.0;
440        }
441        self.total_tokens_sent as f64 / total_original as f64
442    }
443
444    pub fn files_by_token_cost(&self) -> Vec<(String, usize)> {
445        let mut costs: Vec<(String, usize)> = self
446            .entries
447            .iter()
448            .map(|e| (e.path.clone(), e.sent_tokens))
449            .collect();
450        costs.sort_by_key(|b| std::cmp::Reverse(b.1));
451        costs
452    }
453
454    pub fn mode_distribution(&self) -> HashMap<String, usize> {
455        let mut dist: HashMap<String, usize> = HashMap::new();
456        for entry in &self.entries {
457            *dist.entry(entry.mode.clone()).or_insert(0) += 1;
458        }
459        dist
460    }
461
462    pub fn eviction_candidates(&self, keep_count: usize) -> Vec<String> {
463        if self.entries.len() <= keep_count {
464            return Vec::new();
465        }
466        let mut sorted = self.entries.clone();
467        sorted.sort_by_key(|e| e.timestamp);
468        sorted
469            .iter()
470            .take(self.entries.len() - keep_count)
471            .map(|e| e.path.clone())
472            .collect()
473    }
474
475    pub fn remove(&mut self, path: &str) -> bool {
476        if let Some(idx) = self.entries.iter().position(|e| e.path == path) {
477            let entry = &self.entries[idx];
478            self.total_tokens_sent = self.total_tokens_sent.saturating_sub(entry.sent_tokens);
479            self.total_tokens_saved = self
480                .total_tokens_saved
481                .saturating_sub(entry.original_tokens.saturating_sub(entry.sent_tokens));
482            self.entries.remove(idx);
483            true
484        } else {
485            false
486        }
487    }
488
489    /// Clear all entries and reset totals to zero.
490    pub fn reset(&mut self) {
491        let pinned_count = self
492            .entries
493            .iter()
494            .filter(|e| e.state == Some(ContextState::Pinned))
495            .count();
496        self.entries.clear();
497        self.total_tokens_sent = 0;
498        self.total_tokens_saved = 0;
499        if pinned_count > 0 {
500            tracing::info!("{pinned_count} pinned entries were also cleared");
501        }
502    }
503
504    /// Remove specific paths from the ledger. Returns count of entries removed.
505    /// Paths are normalized before matching.
506    pub fn evict_paths(&mut self, paths: &[&str]) -> usize {
507        let mut removed = 0;
508        for path in paths {
509            let normalized = crate::core::pathutil::normalize_tool_path(path);
510            if self.remove(&normalized) {
511                removed += 1;
512            }
513        }
514        removed
515    }
516
517    pub fn save(&self) {
518        self.save_for_agent("default");
519    }
520
521    /// Debounced save: only flushes to disk if >=3s since last save.
522    /// Reduces I/O overhead during burst sequences of tool calls.
523    pub fn save_debounced(&mut self) {
524        let now = std::time::Instant::now();
525        if let Some(last) = self.last_flush
526            && now.duration_since(last) < std::time::Duration::from_secs(3)
527        {
528            return;
529        }
530        self.save();
531        self.last_flush = Some(now);
532    }
533
534    pub fn save_for_agent(&self, agent_id: &str) {
535        if let Ok(path) = ledger_path(agent_id) {
536            if let Some(parent) = path.parent() {
537                let _ = std::fs::create_dir_all(parent);
538            }
539            let _lock = acquire_ledger_lock(&path);
540            if let Ok(json) = serde_json::to_string(self) {
541                atomic_write_json(&path, &json);
542            }
543        }
544    }
545
546    const MAX_LEDGER_ENTRIES: usize = 200;
547    const STALE_AGE_SECS: i64 = 7 * 24 * 3600;
548
549    pub fn prune(&mut self) -> usize {
550        let before = self.entries.len();
551        let now = chrono::Utc::now().timestamp();
552
553        for entry in &mut self.entries {
554            if let Some(phi) = entry.phi {
555                let hours_since = ((now - entry.timestamp) as f64 / 3600.0).max(0.0);
556                let decayed = phi * 0.95_f64.powf(hours_since);
557                entry.phi = Some(decayed.max(0.0));
558            }
559        }
560
561        self.entries
562            .retain(|e| !(e.mode == "error" && e.original_tokens == 0));
563
564        self.entries.retain(|e| {
565            let age = now - e.timestamp;
566            let phi = e.phi.unwrap_or(0.0);
567            !(age > Self::STALE_AGE_SECS && phi < 0.1)
568        });
569
570        let mut seen = std::collections::HashSet::new();
571        self.entries.sort_by_key(|e| std::cmp::Reverse(e.timestamp));
572        self.entries.retain(|e| {
573            // Lexical key only: entries were normalized when written, and the
574            // full variant would `realpath` every persisted path — the daemon
575            // runs this at boot (ContextLedger::load → prune) and stat-ing
576            // stored paths under ~/Documents from a launchd process pops the
577            // macOS TCC prompt (#356).
578            let key = crate::core::pathutil::normalize_tool_path_lexical(&e.path);
579            seen.insert(key)
580        });
581
582        if self.entries.len() > Self::MAX_LEDGER_ENTRIES {
583            self.entries.sort_by(|a, b| {
584                let pa = a.phi.unwrap_or(0.0);
585                let pb = b.phi.unwrap_or(0.0);
586                pb.partial_cmp(&pa).unwrap_or(std::cmp::Ordering::Equal)
587            });
588            self.entries.truncate(Self::MAX_LEDGER_ENTRIES);
589        }
590
591        self.rebuild_totals();
592        before - self.entries.len()
593    }
594
595    fn rebuild_totals(&mut self) {
596        self.total_tokens_sent = self.entries.iter().map(|e| e.sent_tokens).sum();
597        self.total_tokens_saved = self
598            .entries
599            .iter()
600            .map(|e| e.original_tokens.saturating_sub(e.sent_tokens))
601            .sum();
602    }
603
604    pub fn load() -> Self {
605        Self::load_for_agent("default")
606    }
607
608    pub fn load_for_agent(agent_id: &str) -> Self {
609        let mut ledger: Self = ledger_path(agent_id)
610            .ok()
611            .and_then(|p| {
612                let _lock = acquire_ledger_lock(&p);
613                std::fs::read_to_string(p).ok()
614            })
615            .and_then(|s| serde_json::from_str(&s).ok())
616            .unwrap_or_default();
617        if let Some((_model, window)) = crate::hook_handlers::load_detected_model() {
618            ledger.window_size = window;
619        }
620        let pruned = ledger.prune();
621        if pruned > 0 {
622            ledger.save_for_agent(agent_id);
623        }
624        ledger
625    }
626
627    pub fn format_summary(&self) -> String {
628        let pressure = self.pressure();
629        format!(
630            "CTX: {}/{} tokens ({:.0}%), {} files, ratio {:.2}, action: {:?}",
631            self.total_tokens_sent,
632            self.window_size,
633            pressure.utilization * 100.0,
634            self.entries.len(),
635            self.compression_ratio(),
636            pressure.recommendation,
637        )
638    }
639
640    pub fn adjusted_total_saved(&self) -> isize {
641        match crate::core::bounce_tracker::global().lock() {
642            Ok(bt) => bt.adjusted_savings(self.total_tokens_saved),
643            _ => self.total_tokens_saved as isize,
644        }
645    }
646}
647
648#[derive(Debug, Clone)]
649pub struct ReinjectionAction {
650    pub path: String,
651    pub current_mode: String,
652    pub new_mode: String,
653    pub tokens_freed: usize,
654}
655
656#[derive(Debug, Clone)]
657pub struct ReinjectionPlan {
658    pub actions: Vec<ReinjectionAction>,
659    pub total_tokens_freed: usize,
660    pub new_utilization: f64,
661}
662
663impl ContextLedger {
664    pub fn reinjection_plan(
665        &self,
666        intent: &super::intent_engine::StructuredIntent,
667        target_utilization: f64,
668    ) -> ReinjectionPlan {
669        let current_util = self.total_tokens_sent as f64 / self.window_size as f64;
670        if current_util <= target_utilization {
671            return ReinjectionPlan {
672                actions: Vec::new(),
673                total_tokens_freed: 0,
674                new_utilization: current_util,
675            };
676        }
677
678        let tokens_to_free =
679            self.total_tokens_sent - (self.window_size as f64 * target_utilization) as usize;
680
681        let target_set: std::collections::HashSet<&str> = intent
682            .targets
683            .iter()
684            .map(std::string::String::as_str)
685            .collect();
686
687        let mut candidates: Vec<(usize, &LedgerEntry)> = self
688            .entries
689            .iter()
690            .enumerate()
691            .filter(|(_, e)| !target_set.iter().any(|t| e.path.contains(t)))
692            .collect();
693
694        candidates.sort_by(|a, b| {
695            let a_phi = a.1.phi.unwrap_or(0.0);
696            let b_phi = b.1.phi.unwrap_or(0.0);
697            a_phi
698                .partial_cmp(&b_phi)
699                .unwrap_or_else(|| a.1.timestamp.cmp(&b.1.timestamp))
700        });
701
702        let mut actions = Vec::new();
703        let mut freed = 0usize;
704
705        for (_, entry) in &candidates {
706            if freed >= tokens_to_free {
707                break;
708            }
709            if let Some((new_mode, new_tokens)) = downgrade_mode(&entry.mode, entry.sent_tokens) {
710                let saving = entry.sent_tokens.saturating_sub(new_tokens);
711                if saving > 0 {
712                    actions.push(ReinjectionAction {
713                        path: entry.path.clone(),
714                        current_mode: entry.mode.clone(),
715                        new_mode,
716                        tokens_freed: saving,
717                    });
718                    freed += saving;
719                }
720            }
721        }
722
723        let new_sent = self.total_tokens_sent.saturating_sub(freed);
724        let new_utilization = new_sent as f64 / self.window_size as f64;
725
726        ReinjectionPlan {
727            actions,
728            total_tokens_freed: freed,
729            new_utilization,
730        }
731    }
732}
733
734fn downgrade_mode(current_mode: &str, current_tokens: usize) -> Option<(String, usize)> {
735    match current_mode {
736        "full" => Some(("signatures".to_string(), current_tokens / 5)),
737        "aggressive" => Some(("signatures".to_string(), current_tokens / 3)),
738        "signatures" => Some(("map".to_string(), current_tokens / 2)),
739        "map" => Some(("reference".to_string(), current_tokens / 4)),
740        _ => None,
741    }
742}
743
744/// Resolve the Global-Workspace ignition z-score threshold (#6): the
745/// `LEAN_CTX_GWT_IGNITION_Z` env override (must be > 0) wins, else the default
746/// [`GWT_IGNITION_Z`]. Deterministic for a given environment.
747fn ignition_z_threshold() -> f64 {
748    std::env::var("LEAN_CTX_GWT_IGNITION_Z")
749        .ok()
750        .and_then(|v| v.trim().parse::<f64>().ok())
751        .filter(|v| *v > 0.0)
752        .unwrap_or(GWT_IGNITION_Z)
753}
754
755impl Default for ContextLedger {
756    fn default() -> Self {
757        Self::new()
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn new_ledger_is_empty() {
767        let ledger = ContextLedger::new();
768        assert_eq!(ledger.total_tokens_sent, 0);
769        assert_eq!(ledger.entries.len(), 0);
770        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
771    }
772
773    #[test]
774    fn record_tracks_tokens() {
775        let mut ledger = ContextLedger::with_window_size(10000);
776        ledger.record("src/main.rs", "full", 500, 500);
777        ledger.record("src/lib.rs", "signatures", 1000, 200);
778        assert_eq!(ledger.total_tokens_sent, 700);
779        assert_eq!(ledger.total_tokens_saved, 800);
780        assert_eq!(ledger.entries.len(), 2);
781    }
782
783    #[test]
784    fn ignition_broadcasts_high_salience_outlier() {
785        // #6: an item far above the mean salience ignites and is pinned.
786        let mut ledger = ContextLedger::with_window_size(100_000);
787        for i in 0..5 {
788            ledger.record(&format!("low{i}.rs"), "map", 100, 100);
789        }
790        ledger.record("hot.rs", "full", 100, 100);
791        for e in &mut ledger.entries {
792            e.phi = Some(if e.path == "hot.rs" { 0.95 } else { 0.1 });
793        }
794        let ignited = ledger.ignite_high_salience();
795        assert_eq!(ignited, vec!["hot.rs".to_string()]);
796        let hot = ledger.entries.iter().find(|e| e.path == "hot.rs").unwrap();
797        assert_eq!(hot.state, Some(ContextState::Pinned));
798    }
799
800    #[test]
801    fn ignition_skips_small_ledger() {
802        // Below GWT_MIN_ENTRIES the distribution is too small — no ignition.
803        let mut ledger = ContextLedger::with_window_size(100_000);
804        ledger.record("a.rs", "full", 100, 100);
805        ledger.entries[0].phi = Some(0.99);
806        assert!(ledger.ignite_high_salience().is_empty());
807    }
808
809    #[test]
810    fn ignition_is_deterministic() {
811        // Determinism contract (#498): same Phi distribution → same ignitions.
812        let build = || {
813            let mut l = ContextLedger::with_window_size(100_000);
814            for i in 0..5 {
815                l.record(&format!("f{i}.rs"), "map", 100, 100);
816            }
817            for (i, e) in l.entries.iter_mut().enumerate() {
818                e.phi = Some(if i == 0 { 0.95 } else { 0.1 });
819            }
820            l
821        };
822        let mut a = build();
823        let mut b = build();
824        assert_eq!(a.ignite_high_salience(), b.ignite_high_salience());
825    }
826
827    #[test]
828    fn record_updates_existing_entry() {
829        let mut ledger = ContextLedger::with_window_size(10000);
830        ledger.record("src/main.rs", "full", 500, 500);
831        ledger.record("src/main.rs", "signatures", 500, 100);
832        assert_eq!(ledger.entries.len(), 1);
833        assert_eq!(ledger.total_tokens_sent, 100);
834        assert_eq!(ledger.total_tokens_saved, 400);
835    }
836
837    #[test]
838    fn access_count_tracks_rereads() {
839        let mut ledger = ContextLedger::with_window_size(10000);
840        ledger.record("src/main.rs", "full", 500, 500);
841        assert_eq!(ledger.entries[0].access_count, 1);
842        ledger.record("src/main.rs", "signatures", 500, 100);
843        ledger.record("src/main.rs", "map", 500, 50);
844        assert_eq!(ledger.entries[0].access_count, 3);
845        // A different file starts its own count.
846        ledger.record("src/other.rs", "full", 200, 200);
847        let other = ledger.entries.iter().find(|e| e.path == "src/other.rs");
848        assert_eq!(other.map(|e| e.access_count), Some(1));
849    }
850
851    #[test]
852    fn pressure_escalates() {
853        let mut ledger = ContextLedger::with_window_size(1000);
854        ledger.record("a.rs", "full", 600, 600);
855        assert_eq!(
856            ledger.pressure().recommendation,
857            PressureAction::SuggestCompression
858        );
859        ledger.record("b.rs", "full", 200, 200);
860        assert_eq!(
861            ledger.pressure().recommendation,
862            PressureAction::ForceCompression
863        );
864        ledger.record("c.rs", "full", 150, 150);
865        assert_eq!(
866            ledger.pressure().recommendation,
867            PressureAction::EvictLeastRelevant
868        );
869    }
870
871    #[test]
872    fn compression_ratio_accurate() {
873        let mut ledger = ContextLedger::with_window_size(10000);
874        ledger.record("a.rs", "full", 1000, 1000);
875        ledger.record("b.rs", "signatures", 1000, 200);
876        let ratio = ledger.compression_ratio();
877        assert!((ratio - 0.6).abs() < 0.01);
878    }
879
880    #[test]
881    fn eviction_returns_oldest() {
882        let mut ledger = ContextLedger::with_window_size(10000);
883        ledger.record("old.rs", "full", 100, 100);
884        std::thread::sleep(std::time::Duration::from_millis(10));
885        ledger.record("new.rs", "full", 100, 100);
886        let candidates = ledger.eviction_candidates(1);
887        assert_eq!(candidates, vec!["old.rs"]);
888    }
889
890    #[test]
891    fn remove_updates_totals() {
892        let mut ledger = ContextLedger::with_window_size(10000);
893        ledger.record("a.rs", "full", 500, 500);
894        ledger.record("b.rs", "full", 300, 300);
895        assert!(ledger.remove("a.rs"));
896        assert_eq!(ledger.total_tokens_sent, 300);
897        assert_eq!(ledger.entries.len(), 1);
898        assert!(!ledger.remove("nonexistent.rs"));
899    }
900
901    #[test]
902    fn reset_clears_everything() {
903        let mut ledger = ContextLedger::with_window_size(10000);
904        ledger.record("a.rs", "full", 500, 500);
905        ledger.record("b.rs", "full", 300, 300);
906        ledger.reset();
907        assert_eq!(ledger.entries.len(), 0);
908        assert_eq!(ledger.total_tokens_sent, 0);
909        assert_eq!(ledger.total_tokens_saved, 0);
910        assert_eq!(ledger.pressure().recommendation, PressureAction::NoAction);
911    }
912
913    #[test]
914    fn evict_paths_removes_matching() {
915        let mut ledger = ContextLedger::with_window_size(10000);
916        ledger.record("a.rs", "full", 500, 500);
917        ledger.record("b.rs", "full", 300, 300);
918        ledger.record("c.rs", "full", 200, 200);
919        let removed = ledger.evict_paths(&["a.rs", "c.rs", "nonexistent.rs"]);
920        assert_eq!(removed, 2);
921        assert_eq!(ledger.entries.len(), 1);
922        assert_eq!(ledger.entries[0].path, "b.rs");
923        assert_eq!(ledger.total_tokens_sent, 300);
924    }
925
926    #[test]
927    fn mode_distribution_counts() {
928        let mut ledger = ContextLedger::new();
929        ledger.record("a.rs", "full", 100, 100);
930        ledger.record("b.rs", "signatures", 100, 50);
931        ledger.record("c.rs", "full", 100, 100);
932        let dist = ledger.mode_distribution();
933        assert_eq!(dist.get("full"), Some(&2));
934        assert_eq!(dist.get("signatures"), Some(&1));
935    }
936
937    #[test]
938    fn format_summary_includes_key_info() {
939        let mut ledger = ContextLedger::with_window_size(10000);
940        ledger.record("a.rs", "full", 500, 500);
941        let summary = ledger.format_summary();
942        assert!(summary.contains("500/10000"));
943        assert!(summary.contains("1 files"));
944    }
945
946    #[test]
947    fn reinjection_no_action_when_low_pressure() {
948        use crate::core::intent_engine::StructuredIntent;
949
950        let mut ledger = ContextLedger::with_window_size(10000);
951        ledger.record("a.rs", "full", 100, 100);
952        let intent = StructuredIntent::from_query("fix bug in a.rs");
953        let plan = ledger.reinjection_plan(&intent, 0.7);
954        assert!(plan.actions.is_empty());
955        assert_eq!(plan.total_tokens_freed, 0);
956    }
957
958    #[test]
959    fn reinjection_downgrades_non_target_files() {
960        use crate::core::intent_engine::StructuredIntent;
961
962        let mut ledger = ContextLedger::with_window_size(1000);
963        ledger.record("src/target.rs", "full", 400, 400);
964        std::thread::sleep(std::time::Duration::from_millis(10));
965        ledger.record("src/other.rs", "full", 400, 400);
966        std::thread::sleep(std::time::Duration::from_millis(10));
967        ledger.record("src/utils.rs", "full", 200, 200);
968
969        let intent = StructuredIntent::from_query("fix bug in target.rs");
970        let plan = ledger.reinjection_plan(&intent, 0.5);
971
972        assert!(!plan.actions.is_empty());
973        assert!(
974            plan.actions.iter().all(|a| !a.path.contains("target")),
975            "should not downgrade target file"
976        );
977        assert!(plan.total_tokens_freed > 0);
978    }
979
980    #[test]
981    fn reinjection_preserves_targets() {
982        use crate::core::intent_engine::StructuredIntent;
983
984        let mut ledger = ContextLedger::with_window_size(1000);
985        ledger.record("src/auth.rs", "full", 900, 900);
986        let intent = StructuredIntent::from_query("fix bug in auth.rs");
987        let plan = ledger.reinjection_plan(&intent, 0.5);
988        assert!(
989            plan.actions.is_empty(),
990            "should not downgrade target files even under pressure"
991        );
992    }
993
994    #[test]
995    fn downgrade_mode_chain() {
996        assert_eq!(
997            downgrade_mode("full", 1000),
998            Some(("signatures".to_string(), 200))
999        );
1000        assert_eq!(
1001            downgrade_mode("signatures", 200),
1002            Some(("map".to_string(), 100))
1003        );
1004        assert_eq!(
1005            downgrade_mode("map", 100),
1006            Some(("reference".to_string(), 25))
1007        );
1008        assert_eq!(downgrade_mode("reference", 25), None);
1009    }
1010
1011    #[test]
1012    fn record_assigns_item_id() {
1013        let mut ledger = ContextLedger::new();
1014        ledger.record("src/main.rs", "full", 500, 500);
1015        let entry = &ledger.entries[0];
1016        assert!(entry.id.is_some());
1017        assert_eq!(entry.id.as_ref().unwrap().as_str(), "file:src/main.rs");
1018    }
1019
1020    #[test]
1021    fn record_sets_state_to_included() {
1022        let mut ledger = ContextLedger::new();
1023        ledger.record("src/main.rs", "full", 500, 500);
1024        assert_eq!(
1025            ledger.entries[0].state,
1026            Some(crate::core::context_field::ContextState::Included)
1027        );
1028    }
1029
1030    #[test]
1031    fn record_generates_view_costs() {
1032        let mut ledger = ContextLedger::new();
1033        ledger.record("src/main.rs", "full", 5000, 5000);
1034        let vc = ledger.entries[0].view_costs.as_ref().unwrap();
1035        assert_eq!(vc.get(&crate::core::context_field::ViewKind::Full), 5000);
1036        assert_eq!(
1037            vc.get(&crate::core::context_field::ViewKind::Signatures),
1038            1000
1039        );
1040    }
1041
1042    #[test]
1043    fn update_phi_works() {
1044        let mut ledger = ContextLedger::new();
1045        ledger.record("a.rs", "full", 100, 100);
1046        ledger.update_phi("a.rs", 0.85);
1047        assert_eq!(ledger.entries[0].phi, Some(0.85));
1048    }
1049
1050    #[test]
1051    fn set_state_works() {
1052        let mut ledger = ContextLedger::new();
1053        ledger.record("a.rs", "full", 100, 100);
1054        ledger.set_state("a.rs", crate::core::context_field::ContextState::Pinned);
1055        assert_eq!(
1056            ledger.entries[0].state,
1057            Some(crate::core::context_field::ContextState::Pinned)
1058        );
1059    }
1060
1061    #[test]
1062    fn items_by_state_filters() {
1063        let mut ledger = ContextLedger::new();
1064        ledger.record("a.rs", "full", 100, 100);
1065        ledger.record("b.rs", "full", 100, 100);
1066        ledger.set_state("b.rs", crate::core::context_field::ContextState::Excluded);
1067        let included = ledger.items_by_state(crate::core::context_field::ContextState::Included);
1068        assert_eq!(included.len(), 1);
1069        assert_eq!(included[0].path, "a.rs");
1070    }
1071
1072    #[test]
1073    fn eviction_by_phi_prefers_low_phi() {
1074        let mut ledger = ContextLedger::with_window_size(10000);
1075        ledger.record("high.rs", "full", 100, 100);
1076        ledger.update_phi("high.rs", 0.9);
1077        ledger.record("low.rs", "full", 100, 100);
1078        ledger.update_phi("low.rs", 0.1);
1079        let candidates = ledger.eviction_candidates_by_phi(1);
1080        assert_eq!(candidates, vec!["low.rs"]);
1081    }
1082
1083    #[test]
1084    fn eviction_by_phi_skips_pinned() {
1085        let mut ledger = ContextLedger::with_window_size(10000);
1086        ledger.record("pinned.rs", "full", 100, 100);
1087        ledger.update_phi("pinned.rs", 0.01);
1088        ledger.set_state(
1089            "pinned.rs",
1090            crate::core::context_field::ContextState::Pinned,
1091        );
1092        ledger.record("normal.rs", "full", 100, 100);
1093        ledger.update_phi("normal.rs", 0.5);
1094        let candidates = ledger.eviction_candidates_by_phi(1);
1095        assert_eq!(candidates, vec!["normal.rs"]);
1096    }
1097
1098    #[test]
1099    fn mark_stale_by_hash_detects_change() {
1100        let mut ledger = ContextLedger::new();
1101        ledger.record("a.rs", "full", 100, 100);
1102        ledger.entries[0].source_hash = Some("hash_v1".to_string());
1103        ledger.mark_stale_by_hash("a.rs", "hash_v2");
1104        assert_eq!(
1105            ledger.entries[0].state,
1106            Some(crate::core::context_field::ContextState::Stale)
1107        );
1108    }
1109
1110    #[test]
1111    fn find_by_id_works() {
1112        let mut ledger = ContextLedger::new();
1113        ledger.record("src/lib.rs", "full", 100, 100);
1114        let id = crate::core::context_field::ContextItemId::from_file("src/lib.rs");
1115        assert!(ledger.find_by_id(&id).is_some());
1116    }
1117
1118    #[test]
1119    fn phi_recomputed_on_reread_not_sticky() {
1120        // #2: Phi must track time-variant salience, not freeze on first read.
1121        let _env = crate::core::data_dir::test_env_lock();
1122        let dir = tempfile::tempdir().unwrap();
1123        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
1124
1125        let mut ledger = ContextLedger::with_window_size(100_000);
1126        // First read carries a task whose keyword matches the path → relevance up.
1127        ledger.record_with_task(
1128            "src/authentication.rs",
1129            "full",
1130            2000,
1131            2000,
1132            Some("fix authentication login flow"),
1133        );
1134        let phi_with_task = ledger.entries[0].phi.unwrap();
1135        // Re-read with no task context → relevance collapses, so the blended Phi
1136        // must move. Before the fix this stayed frozen at the first value.
1137        ledger.record_with_task("src/authentication.rs", "full", 2000, 2000, None);
1138        let phi_after = ledger.entries[0].phi.unwrap();
1139        assert_ne!(
1140            phi_with_task, phi_after,
1141            "Phi must be recomputed on re-read (#2)"
1142        );
1143        assert!(
1144            phi_after < phi_with_task,
1145            "dropping task relevance should lower Phi ({phi_with_task} -> {phi_after})"
1146        );
1147    }
1148
1149    #[test]
1150    fn upsert_sets_source_hash_and_kind() {
1151        let mut ledger = ContextLedger::new();
1152        ledger.upsert(
1153            "src/main.rs",
1154            "full",
1155            500,
1156            500,
1157            Some("sha256_abc"),
1158            crate::core::context_field::ContextKind::File,
1159            None,
1160        );
1161        let entry = &ledger.entries[0];
1162        assert_eq!(entry.source_hash.as_deref(), Some("sha256_abc"));
1163        assert_eq!(
1164            entry.kind,
1165            Some(crate::core::context_field::ContextKind::File)
1166        );
1167    }
1168}