Skip to main content

lean_ctx/core/
cooccurrence.rs

1//! Hebbian file co-access graph — "files that fire together, wire together".
2//!
3//! ## The idea (neuroscience → retrieval)
4//!
5//! Hebbian theory: synapses between co-active neurons strengthen (long-term
6//! potentiation, LTP), while unused ones weaken (long-term depression / the
7//! Ebbinghaus forgetting curve). We apply the same rule to files: whenever a
8//! task surfaces a set of files *together*, we strengthen the association
9//! between every pair; on each update all weights decay slightly, so stale
10//! associations fade. Over time the graph learns the project's real working
11//! paths — which the static import/AST graph cannot capture.
12//!
13//! The learned association is an additive retrieval signal: given a file the
14//! agent is looking at, [`related`] returns the files history says tend to be
15//! touched alongside it.
16//!
17//! The store is a small per-project JSON file; reads/writes are whole-file and
18//! cheap because the graph is pruned aggressively (decay + min-weight + caps).
19
20use std::collections::HashMap;
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25/// Multiplicative decay applied to every edge on each `record` — the forgetting
26/// curve. 0.98 ⇒ an association roughly halves after ~34 un-reinforced updates.
27const DECAY: f64 = 0.98;
28/// Edges weaker than this are pruned (kept the graph small + relevant).
29const MIN_WEIGHT: f64 = 0.08;
30/// Potentiation increment for a co-access (LTP step).
31const LTP_INCREMENT: f64 = 1.0;
32/// Cap on neighbours kept per file (strongest retained) to bound memory.
33const MAX_NEIGHBORS: usize = 32;
34/// Cap on total tracked files; beyond it new files are still recorded but the
35/// weakest-degree files are evicted to stay bounded.
36const MAX_FILES: usize = 5_000;
37/// A single record never associates more than this many files (avoids O(n²)
38/// blow-ups when a tool surfaces a huge file set).
39const MAX_RECORD_FILES: usize = 24;
40
41/// Persistent, decaying co-access graph for one project.
42#[derive(Debug, Default, Clone, Serialize, Deserialize)]
43pub struct CoAccessGraph {
44    /// `file → (neighbour → weight)`. Symmetric by construction.
45    edges: HashMap<String, HashMap<String, f64>>,
46}
47
48impl CoAccessGraph {
49    /// Reinforce the mutual association of every pair in `files` (LTP) after
50    /// decaying the whole graph one step (global forgetting). Self-pairs and
51    /// duplicates are ignored. Bounded work: at most `MAX_RECORD_FILES²` pairs.
52    pub fn record(&mut self, files: &[String]) {
53        // Distinct, capped input.
54        let mut uniq: Vec<&String> = Vec::new();
55        for f in files {
56            if !f.is_empty() && !uniq.contains(&f) {
57                uniq.push(f);
58                if uniq.len() >= MAX_RECORD_FILES {
59                    break;
60                }
61            }
62        }
63        if uniq.len() < 2 {
64            return; // nothing to associate
65        }
66
67        self.decay_all();
68
69        for i in 0..uniq.len() {
70            for j in (i + 1)..uniq.len() {
71                self.bump(uniq[i], uniq[j]);
72                self.bump(uniq[j], uniq[i]);
73            }
74        }
75
76        self.prune();
77    }
78
79    /// Reinforce the association of `focus` with each file in `others` — a
80    /// **star**, not a clique — after one global decay step.
81    ///
82    /// This is the right model for *streaming* access where one new file enters
83    /// the working set (e.g. each `ctx_read`): it associates the newcomer with
84    /// the recent set without re-reinforcing the already-known pairs among
85    /// `others` (which [`CoAccessGraph::record`] would, biasing toward early files).
86    pub fn record_focus(&mut self, focus: &str, others: &[String]) {
87        if focus.is_empty() {
88            return;
89        }
90        let mut uniq: Vec<&String> = Vec::new();
91        for f in others {
92            if !f.is_empty() && f.as_str() != focus && !uniq.contains(&f) {
93                uniq.push(f);
94                if uniq.len() >= MAX_RECORD_FILES {
95                    break;
96                }
97            }
98        }
99        if uniq.is_empty() {
100            return; // nothing to associate
101        }
102
103        self.decay_all();
104        for other in uniq {
105            self.bump(focus, other);
106            self.bump(other, focus);
107        }
108        self.prune();
109    }
110
111    /// Files most strongly associated with `file`, strongest first.
112    pub fn related(&self, file: &str, top_k: usize) -> Vec<(String, f64)> {
113        let Some(neighbours) = self.edges.get(file) else {
114            return Vec::new();
115        };
116        let mut v: Vec<(String, f64)> = neighbours.iter().map(|(k, &w)| (k.clone(), w)).collect();
117        v.sort_by(|a, b| b.1.total_cmp(&a.1));
118        v.truncate(top_k);
119        v
120    }
121
122    /// Canonical *undirected* co-access edges `(from, to, weight)` with
123    /// `from <= to`, strongest first, weights `>= min_weight`, capped at
124    /// `max_edges`. The graph is symmetric by construction, but asymmetric
125    /// pruning can leave the two directions with slightly different weights, so
126    /// the stronger direction wins. Deterministic order (weight desc, then path).
127    pub fn canonical_edges(&self, min_weight: f64, max_edges: usize) -> Vec<(String, String, f64)> {
128        let mut best: HashMap<(String, String), f64> = HashMap::new();
129        for (from, neighbours) in &self.edges {
130            for (to, &w) in neighbours {
131                if w < min_weight || from == to {
132                    continue;
133                }
134                let key = if from <= to {
135                    (from.clone(), to.clone())
136                } else {
137                    (to.clone(), from.clone())
138                };
139                let slot = best.entry(key).or_insert(0.0);
140                if w > *slot {
141                    *slot = w;
142                }
143            }
144        }
145        let mut out: Vec<(String, String, f64)> =
146            best.into_iter().map(|((a, b), w)| (a, b, w)).collect();
147        out.sort_by(|x, y| {
148            y.2.total_cmp(&x.2)
149                .then_with(|| x.0.cmp(&y.0))
150                .then_with(|| x.1.cmp(&y.1))
151        });
152        out.truncate(max_edges);
153        out
154    }
155
156    fn bump(&mut self, from: &str, to: &str) {
157        let entry = self.edges.entry(from.to_string()).or_default();
158        *entry.entry(to.to_string()).or_insert(0.0) += LTP_INCREMENT;
159    }
160
161    fn decay_all(&mut self) {
162        for neighbours in self.edges.values_mut() {
163            for w in neighbours.values_mut() {
164                *w *= DECAY;
165            }
166        }
167    }
168
169    fn prune(&mut self) {
170        for neighbours in self.edges.values_mut() {
171            neighbours.retain(|_, &mut w| w >= MIN_WEIGHT);
172            if neighbours.len() > MAX_NEIGHBORS {
173                let mut kept: Vec<(String, f64)> =
174                    neighbours.iter().map(|(k, &w)| (k.clone(), w)).collect();
175                kept.sort_by(|a, b| b.1.total_cmp(&a.1));
176                kept.truncate(MAX_NEIGHBORS);
177                *neighbours = kept.into_iter().collect();
178            }
179        }
180        self.edges.retain(|_, neighbours| !neighbours.is_empty());
181
182        if self.edges.len() > MAX_FILES {
183            // Evict the lowest-degree files (least-connected memories).
184            let mut by_degree: Vec<(String, usize)> = self
185                .edges
186                .iter()
187                .map(|(k, n)| (k.clone(), n.len()))
188                .collect();
189            by_degree.sort_by_key(|(_, d)| *d);
190            let evict = self.edges.len() - MAX_FILES;
191            for (file, _) in by_degree.into_iter().take(evict) {
192                self.edges.remove(&file);
193            }
194        }
195    }
196}
197
198// ── Persistence (one small JSON file per project) ──────────────────────────
199
200fn store_path(project_root: &str) -> Option<PathBuf> {
201    let normalized = crate::core::graph_index::normalize_project_root(project_root);
202    let hash = crate::core::project_hash::hash_project_root(&normalized);
203    crate::core::paths::state_dir()
204        .ok()
205        .map(|d| d.join("cooccurrence").join(format!("{hash}.json")))
206}
207
208/// Load the co-access graph for `project_root` (empty if none / unreadable).
209pub fn load(project_root: &str) -> CoAccessGraph {
210    let Some(path) = store_path(project_root) else {
211        return CoAccessGraph::default();
212    };
213    std::fs::read_to_string(&path)
214        .ok()
215        .and_then(|s| serde_json::from_str(&s).ok())
216        .unwrap_or_default()
217}
218
219fn save(project_root: &str, graph: &CoAccessGraph) {
220    let Some(path) = store_path(project_root) else {
221        return;
222    };
223    if let Some(parent) = path.parent() {
224        let _ = std::fs::create_dir_all(parent);
225    }
226    if let Ok(json) = serde_json::to_string(graph) {
227        let _ = std::fs::write(&path, json);
228    }
229}
230
231/// Record that `files` were accessed together for one task, persisting the
232/// reinforced graph. No-op for fewer than two distinct files.
233pub fn record_access(project_root: &str, files: &[String]) {
234    if files.len() < 2 {
235        return;
236    }
237    let mut graph = load(project_root);
238    graph.record(files);
239    save(project_root, &graph);
240}
241
242/// Files historically co-accessed with `file`, strongest association first.
243pub fn related(project_root: &str, file: &str, top_k: usize) -> Vec<(String, f64)> {
244    load(project_root).related(file, top_k)
245}
246
247// ── Traversal-edge surface (gated, repo-relative) ──────────────────────────
248//
249// These wrappers (used by ctx_read / ctx_semantic_search / the dashboard) honor
250// the `[graph] traversal_edges` config and normalize paths to the repo-relative
251// form the code graph uses, so learned edges line up with static edges (#289).
252
253/// Whether traversal (co-access) edges are enabled (`[graph] traversal_edges`).
254pub fn traversal_enabled() -> bool {
255    crate::core::config::Config::load().graph.traversal_edges
256}
257
258/// Normalize an absolute-or-relative path to the repo-relative form used as the
259/// co-access / graph key (e.g. `/repo/src/a.rs` → `src/a.rs`).
260fn to_repo_rel(path: &str, project_root: &str) -> String {
261    let p = path.replace('\\', "/");
262    let root = project_root.trim_end_matches('/').replace('\\', "/");
263    if !root.is_empty() {
264        let prefix = format!("{root}/");
265        if let Some(rest) = p.strip_prefix(&prefix) {
266            return rest.to_string();
267        }
268    }
269    p.trim_start_matches('/').to_string()
270}
271
272/// Record a *streaming* co-access: the just-touched `focus` file against the
273/// recent working set `others` (star association). Paths are normalized to
274/// repo-relative. No-op when traversal edges are disabled or there is nothing
275/// to associate. Persisted.
276pub fn record_focus_access(project_root: &str, focus: &str, others: &[String]) {
277    if !traversal_enabled() {
278        return;
279    }
280    let focus_rel = to_repo_rel(focus, project_root);
281    if focus_rel.is_empty() {
282        return;
283    }
284    let others_rel: Vec<String> = others
285        .iter()
286        .map(|o| to_repo_rel(o, project_root))
287        .filter(|o| !o.is_empty() && o != &focus_rel)
288        .collect();
289    if others_rel.is_empty() {
290        return;
291    }
292    let mut graph = load(project_root);
293    graph.record_focus(&focus_rel, &others_rel);
294    save(project_root, &graph);
295}
296
297/// Record that a *set* of files was surfaced together (e.g. search results)
298/// after normalizing to repo-relative. No-op when disabled or <2 distinct files.
299pub fn record_set_access(project_root: &str, files: &[String]) {
300    if !traversal_enabled() {
301        return;
302    }
303    let rel: Vec<String> = files
304        .iter()
305        .map(|f| to_repo_rel(f, project_root))
306        .filter(|f| !f.is_empty())
307        .collect();
308    record_access(project_root, &rel);
309}
310
311/// Canonical undirected co-access edges for `project_root` (strongest first),
312/// for dashboard overlay and graph folding. Empty when traversal edges are off.
313pub fn export_edges(
314    project_root: &str,
315    min_weight: f64,
316    max_edges: usize,
317) -> Vec<(String, String, f64)> {
318    if !traversal_enabled() {
319        return Vec::new();
320    }
321    load(project_root).canonical_edges(min_weight, max_edges)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn co_access_strengthens_association() {
330        let mut g = CoAccessGraph::default();
331        g.record(&["a.rs".into(), "b.rs".into()]);
332        let rel = g.related("a.rs", 5);
333        assert_eq!(rel.len(), 1);
334        assert_eq!(rel[0].0, "b.rs");
335        assert!(rel[0].1 > 0.0);
336    }
337
338    #[test]
339    fn repeated_co_access_outweighs_single() {
340        let mut g = CoAccessGraph::default();
341        for _ in 0..5 {
342            g.record(&["x.rs".into(), "y.rs".into()]);
343        }
344        g.record(&["x.rs".into(), "z.rs".into()]);
345        let rel = g.related("x.rs", 5);
346        // y was reinforced 5×, z once → y must rank first.
347        assert_eq!(rel[0].0, "y.rs");
348        assert!(rel.iter().any(|(f, _)| f == "z.rs"));
349        assert!(rel[0].1 > rel.iter().find(|(f, _)| f == "z.rs").unwrap().1);
350    }
351
352    #[test]
353    fn weak_associations_are_pruned_by_decay() {
354        let mut g = CoAccessGraph::default();
355        g.record(&["a.rs".into(), "b.rs".into()]);
356        // Hammer an unrelated pair so the a–b edge decays below MIN_WEIGHT.
357        for _ in 0..400 {
358            g.record(&["c.rs".into(), "d.rs".into()]);
359        }
360        assert!(
361            g.related("a.rs", 5).is_empty(),
362            "decayed association should be pruned"
363        );
364        assert!(!g.related("c.rs", 5).is_empty());
365    }
366
367    #[test]
368    fn single_file_record_is_noop() {
369        let mut g = CoAccessGraph::default();
370        g.record(&["lonely.rs".into()]);
371        assert!(g.related("lonely.rs", 5).is_empty());
372    }
373
374    #[test]
375    fn association_is_symmetric() {
376        let mut g = CoAccessGraph::default();
377        g.record(&["one.rs".into(), "two.rs".into()]);
378        assert_eq!(g.related("one.rs", 5)[0].0, "two.rs");
379        assert_eq!(g.related("two.rs", 5)[0].0, "one.rs");
380    }
381
382    #[test]
383    fn serializes_round_trip() {
384        // Deterministic: exercises the persistence *format* (the on-disk path
385        // uses this same serde round-trip) without touching the process-global
386        // data-dir env var, which other tests mutate concurrently.
387        let mut g = CoAccessGraph::default();
388        g.record(&["alpha.rs".into(), "beta.rs".into()]);
389        let json = serde_json::to_string(&g).unwrap();
390        let restored: CoAccessGraph = serde_json::from_str(&json).unwrap();
391        let rel = restored.related("alpha.rs", 5);
392        assert_eq!(rel.len(), 1);
393        assert_eq!(rel[0].0, "beta.rs");
394    }
395
396    #[test]
397    fn neighbours_are_capped() {
398        let mut g = CoAccessGraph::default();
399        // Pair one hub file with many distinct others across separate records
400        // so its neighbour set exceeds the cap before pruning.
401        for i in 0..(MAX_NEIGHBORS + 20) {
402            g.record(&["hub.rs".into(), format!("f{i}.rs")]);
403        }
404        assert!(g.related("hub.rs", 1000).len() <= MAX_NEIGHBORS);
405    }
406
407    #[test]
408    fn record_focus_is_a_star_not_a_clique() {
409        let mut g = CoAccessGraph::default();
410        // `new.rs` enters a working set of {a.rs, b.rs}.
411        g.record_focus("new.rs", &["a.rs".into(), "b.rs".into()]);
412        // It associates with both members of the set...
413        assert_eq!(g.related("new.rs", 5).len(), 2);
414        // ...but a.rs and b.rs are NOT associated with each other (star, not clique).
415        assert!(g.related("a.rs", 5).iter().all(|(f, _)| f != "b.rs"));
416        assert_eq!(g.related("a.rs", 5)[0].0, "new.rs");
417    }
418
419    #[test]
420    fn record_focus_ignores_self_and_empty() {
421        let mut g = CoAccessGraph::default();
422        g.record_focus("x.rs", &["x.rs".into(), String::new()]);
423        assert!(g.related("x.rs", 5).is_empty());
424    }
425
426    #[test]
427    fn canonical_edges_are_undirected_and_sorted() {
428        let mut g = CoAccessGraph::default();
429        for _ in 0..3 {
430            g.record(&["a.rs".into(), "b.rs".into()]);
431        }
432        g.record(&["a.rs".into(), "c.rs".into()]);
433        let edges = g.canonical_edges(0.0, 10);
434        // a–b appears once (undirected), not as both a→b and b→a.
435        let ab = edges
436            .iter()
437            .filter(|(f, t, _)| (f == "a.rs" && t == "b.rs") || (f == "b.rs" && t == "a.rs"))
438            .count();
439        assert_eq!(ab, 1);
440        // Canonical `from <= to` ordering.
441        assert!(edges.iter().all(|(f, t, _)| f <= t));
442        // Strongest first: a–b (3×) ranks before a–c (1×).
443        assert_eq!((edges[0].0.as_str(), edges[0].1.as_str()), ("a.rs", "b.rs"));
444    }
445
446    #[test]
447    fn canonical_edges_respect_min_weight_and_cap() {
448        let mut g = CoAccessGraph::default();
449        g.record(&["a.rs".into(), "b.rs".into()]);
450        assert!(
451            g.canonical_edges(100.0, 10).is_empty(),
452            "min_weight filters all"
453        );
454        assert!(g.canonical_edges(0.0, 0).is_empty(), "cap 0 yields nothing");
455    }
456
457    #[test]
458    fn to_repo_rel_strips_project_root() {
459        assert_eq!(to_repo_rel("/repo/src/a.rs", "/repo"), "src/a.rs");
460        assert_eq!(to_repo_rel("/repo/src/a.rs", "/repo/"), "src/a.rs");
461        assert_eq!(to_repo_rel("src/a.rs", "/repo"), "src/a.rs");
462        assert_eq!(to_repo_rel("/other/x.rs", "/repo"), "other/x.rs");
463    }
464}