1use std::collections::HashMap;
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25const DECAY: f64 = 0.98;
28const MIN_WEIGHT: f64 = 0.08;
30const LTP_INCREMENT: f64 = 1.0;
32const MAX_NEIGHBORS: usize = 32;
34const MAX_FILES: usize = 5_000;
37const MAX_RECORD_FILES: usize = 24;
40
41#[derive(Debug, Default, Clone, Serialize, Deserialize)]
43pub struct CoAccessGraph {
44 edges: HashMap<String, HashMap<String, f64>>,
46}
47
48impl CoAccessGraph {
49 pub fn record(&mut self, files: &[String]) {
53 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; }
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 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; }
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 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 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 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
198fn 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
208pub 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
231pub 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
242pub fn related(project_root: &str, file: &str, top_k: usize) -> Vec<(String, f64)> {
244 load(project_root).related(file, top_k)
245}
246
247pub fn traversal_enabled() -> bool {
255 crate::core::config::Config::load().graph.traversal_edges
256}
257
258fn 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
272pub 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
297pub 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
311pub 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 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 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 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 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 g.record_focus("new.rs", &["a.rs".into(), "b.rs".into()]);
412 assert_eq!(g.related("new.rs", 5).len(), 2);
414 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 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 assert!(edges.iter().all(|(f, t, _)| f <= t));
442 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}