Skip to main content

lean_ctx/core/
fep_prefetch.rs

1//! FEP prefetch (#9): active-inference-style warmup.
2//!
3//! The Free-Energy Principle frames cognition as minimizing *expected* surprise:
4//! an agent acts to make the world match its predictions. Applied to context, the
5//! cheapest way to avoid the surprise of a missing file is to surface the files
6//! most likely to be needed next — *before* they are asked for.
7//!
8//! We estimate that likelihood from the persistent co-access graph
9//! ([`crate::core::cooccurrence`], a Hebbian "files read together" memory) and
10//! pick the strongest associations that are not already in context. The selection
11//! is a deterministic argmax over learned association weight — no sampling — so it
12//! respects the determinism contract (#498). Prefetch is a *suggestion* (warmup
13//! hint), never an automatic read, so it can never change a tool's output body.
14
15use std::collections::HashSet;
16
17use crate::core::context_ledger::ContextLedger;
18
19/// Minimum co-access weight for a file to be worth prefetching — filters noise so
20/// only genuinely-associated files are surfaced.
21const MIN_PREFETCH_WEIGHT: f64 = 0.15;
22/// Maximum prefetch suggestions surfaced per read.
23const MAX_PREFETCH: usize = 3;
24
25/// Deterministic expected-info-gain prefetch candidates for `path`: co-accessed
26/// files (by learned association weight, strongest first) that are NOT already
27/// loaded in `ledger` — an already-loaded file offers no information gain.
28/// Returns `(path, weight)` pairs, empty when nothing clears the threshold.
29pub fn prefetch_candidates(
30    project_root: &str,
31    path: &str,
32    ledger: &ContextLedger,
33) -> Vec<(String, f64)> {
34    let norm = crate::core::pathutil::normalize_tool_path(path);
35    let loaded: HashSet<String> = ledger
36        .entries
37        .iter()
38        .map(|e| crate::core::pathutil::normalize_tool_path(&e.path))
39        .collect();
40
41    crate::core::cooccurrence::related(project_root, &norm, MAX_PREFETCH * 2)
42        .into_iter()
43        .filter(|(p, w)| {
44            *w >= MIN_PREFETCH_WEIGHT
45                && p != &norm
46                && !loaded.contains(&crate::core::pathutil::normalize_tool_path(p))
47        })
48        .take(MAX_PREFETCH)
49        .collect()
50}
51
52/// Format an FEP prefetch hint for the agent, or `None` when there is nothing
53/// worth warming. Registers activity ([`crate::core::introspect`]) only when a
54/// real suggestion is produced, so `introspect cognition` reflects genuine use.
55pub fn prefetch_hint(project_root: &str, path: &str, ledger: &ContextLedger) -> Option<String> {
56    let candidates = prefetch_candidates(project_root, path, ledger);
57    if candidates.is_empty() {
58        return None;
59    }
60    crate::core::introspect::tick("fep_prefetch");
61    let names: Vec<String> = candidates
62        .iter()
63        .map(|(p, _)| crate::core::protocol::shorten_path(p))
64        .collect();
65    Some(format!("Likely next (co-accessed): {}", names.join(", ")))
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn empty_graph_yields_no_prefetch() {
74        let _env = crate::core::data_dir::test_env_lock();
75        let dir = tempfile::tempdir().unwrap();
76        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
77        let project = tempfile::tempdir().unwrap();
78        let root = project.path().to_string_lossy().to_string();
79
80        let ledger = ContextLedger::new();
81        assert!(prefetch_candidates(&root, "src/a.rs", &ledger).is_empty());
82        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
83    }
84
85    #[test]
86    fn co_accessed_file_is_suggested() {
87        // #9: after a co-access burst (a,b), reading A suggests warming B.
88        let _env = crate::core::data_dir::test_env_lock();
89        let dir = tempfile::tempdir().unwrap();
90        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
91        let project = tempfile::tempdir().unwrap();
92        let root = project.path().to_string_lossy().to_string();
93
94        // Build a strong association by recording the pair several times.
95        for _ in 0..5 {
96            crate::core::cooccurrence::record_access(
97                &root,
98                &["src/a.rs".to_string(), "src/b.rs".to_string()],
99            );
100        }
101
102        // Only A is loaded; B should be the prefetch suggestion.
103        let mut ledger = ContextLedger::new();
104        ledger.record("src/a.rs", "full", 100, 100);
105        let cands = prefetch_candidates(&root, "src/a.rs", &ledger);
106        assert!(
107            cands.iter().any(|(p, _)| p.contains("b.rs")),
108            "co-accessed B should be suggested, got {cands:?}"
109        );
110        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
111    }
112
113    #[test]
114    fn already_loaded_file_is_not_suggested() {
115        // #9: a file already in context offers no info gain → not prefetched.
116        let _env = crate::core::data_dir::test_env_lock();
117        let dir = tempfile::tempdir().unwrap();
118        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
119        let project = tempfile::tempdir().unwrap();
120        let root = project.path().to_string_lossy().to_string();
121
122        for _ in 0..5 {
123            crate::core::cooccurrence::record_access(
124                &root,
125                &["src/a.rs".to_string(), "src/b.rs".to_string()],
126            );
127        }
128        let mut ledger = ContextLedger::new();
129        ledger.record("src/a.rs", "full", 100, 100);
130        ledger.record("src/b.rs", "full", 100, 100);
131        let cands = prefetch_candidates(&root, "src/a.rs", &ledger);
132        assert!(
133            !cands.iter().any(|(p, _)| p.contains("b.rs")),
134            "already-loaded B must not be prefetched"
135        );
136        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
137    }
138}