1use crate::algo::LouvainConfig;
45use crate::db::GraphDb;
46use crate::repograph::facts::{rank, str_prop};
47use crate::repograph::render::{basename, cluster_name, common_dir_prefix, sanitize};
48use core_storage::fs::Fs;
49use core_storage::Value;
50use serde::Serialize;
51use std::collections::{BTreeMap, BTreeSet};
52use std::time::{Duration, Instant};
53
54pub(super) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
56const SYNCED_AT: &str = "synced_at";
59const CO_CHANGED_MIN_WEIGHT: f64 = 0.3;
61const MAX_KEY_FILES: usize = 5;
63const MAX_OWNERS: usize = 5;
64const MAX_HOT: usize = 5;
65const MIN_CLUSTER: usize = 2;
67const DAMPING: f64 = 0.85;
69const MAX_ITERS: u32 = 50;
70const TOL: f64 = 1e-6;
71const SECS_PER_DAY: i64 = 86_400;
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct MapOptions {
76 pub max_communities: usize,
78 pub max_samples: usize,
80 pub hot_days: i64,
82 pub budget_ms: u64,
84 pub now_ts: Option<i64>,
88}
89
90impl Default for MapOptions {
91 fn default() -> Self {
92 Self {
93 max_communities: 8,
94 max_samples: 3,
95 hot_days: 90,
96 budget_ms: 3_000,
97 now_ts: None,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct SyncInfo {
106 pub sha: String,
108 pub synced_at: Option<i64>,
112 pub age_secs: Option<i64>,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize)]
119pub struct MapCommunity {
120 pub name: String,
123 pub dir: String,
126 pub size: usize,
128 pub cohesion: f64,
130 pub samples: Vec<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize)]
136pub struct RepoMap {
137 pub files: usize,
138 pub symbols: usize,
139 pub commits: usize,
140 pub authors: usize,
141 pub last_sync: Option<SyncInfo>,
143 pub communities: Vec<MapCommunity>,
144 pub key_files: Vec<(String, f64)>,
146 pub owners: Vec<(String, usize)>,
148 pub hot_files: Vec<(String, usize)>,
150 pub hot_days: i64,
152 pub stale_concepts: usize,
154 pub questions: Vec<String>,
156 pub truncated: bool,
158}
159
160pub(super) fn spent(deadline: Option<Instant>) -> bool {
162 deadline.is_some_and(|dl| Instant::now() >= dl)
163}
164
165fn now_unix() -> i64 {
168 std::time::SystemTime::now()
169 .duration_since(std::time::UNIX_EPOCH)
170 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
171}
172
173fn remaining_ms(deadline: Option<Instant>) -> u64 {
176 match deadline {
177 None => 0,
178 Some(dl) => u64::try_from(dl.saturating_duration_since(Instant::now()).as_millis())
179 .unwrap_or(u64::MAX)
180 .max(1),
181 }
182}
183
184#[must_use]
193pub fn repo_map<F: Fs>(db: &GraphDb<F>, opts: &MapOptions) -> RepoMap {
194 let deadline =
195 (opts.budget_ms > 0).then(|| Instant::now() + Duration::from_millis(opts.budget_ms));
196 let mut truncated = false;
197
198 let mut file_keys: Vec<String> = db
199 .nodes_with_label("File")
200 .iter()
201 .map(|n| n.key().to_string())
202 .collect();
203 file_keys.sort();
204 let files = file_keys.len();
205 let symbols = db.nodes_with_label("Symbol").len();
206 let authors = db.nodes_with_label("Author").len();
207
208 let mut commit_ts: BTreeMap<String, i64> = BTreeMap::new();
210 for n in db.nodes_with_label("Commit") {
211 if let Some(Value::Int(ts)) = n.prop("ts") {
212 commit_ts.insert(n.key().to_string(), ts);
213 }
214 }
215 let commits = db.nodes_with_label("Commit").len();
216 let now = opts.now_ts.or_else(|| commit_ts.values().copied().max());
222 let sync_now = opts.now_ts.unwrap_or_else(now_unix);
223
224 let mut map = RepoMap {
225 files,
226 symbols,
227 commits,
228 authors,
229 last_sync: None,
230 communities: Vec::new(),
231 key_files: Vec::new(),
232 owners: Vec::new(),
233 hot_files: Vec::new(),
234 hot_days: opts.hot_days,
235 stale_concepts: 0,
236 questions: Vec::new(),
237 truncated: false,
238 };
239 if files == 0 {
240 return map; }
242
243 map.last_sync = str_prop(db, SYNC_KEY, "sha").map(|sha| {
244 let synced_at = match db.node_ref(SYNC_KEY).and_then(|n| n.prop(SYNCED_AT)) {
245 Some(Value::Int(at)) => Some(at),
246 _ => None, };
248 SyncInfo {
249 sha: sanitize(&sha),
250 synced_at,
251 age_secs: synced_at.map(|at| sync_now - at),
252 }
253 });
254
255 let scores = if spent(deadline) {
258 truncated = true;
259 Vec::new()
260 } else {
261 let (scores, hit_budget) = file_pagerank(db, &file_keys, deadline);
262 truncated |= hit_budget;
263 scores
264 };
265 let by_score: BTreeMap<&str, f64> = scores.iter().map(|(k, s)| (k.as_str(), *s)).collect();
266 map.key_files = scores
267 .iter()
268 .take(MAX_KEY_FILES)
269 .map(|(k, s)| (sanitize(k), *s))
270 .collect();
271
272 if !truncated && !spent(deadline) {
274 let report = db.communities(&LouvainConfig {
275 edge_types: vec!["CO_CHANGED".to_string(), "IMPORTS".to_string()],
280 weight_prop: Some("score".to_string()),
281 min_weight: Some(CO_CHANGED_MIN_WEIGHT),
282 budget_ms: remaining_ms(deadline),
283 node_label: Some("File".to_string()),
284 ..LouvainConfig::default()
285 });
286 truncated |= report.truncated;
287 for c in report
288 .communities
289 .iter()
290 .filter(|c| c.members.len() >= MIN_CLUSTER)
291 .take(opts.max_communities)
292 {
293 let mut ranked: Vec<(String, f64)> = c
294 .members
295 .iter()
296 .map(|k| (k.clone(), by_score.get(k.as_str()).copied().unwrap_or(0.0)))
297 .collect();
298 rank(&mut ranked);
299 map.communities.push(MapCommunity {
300 name: sanitize(&cluster_name(&c.members)),
301 dir: sanitize(&common_dir_prefix(&c.members)),
302 size: c.members.len(),
303 cohesion: c.cohesion,
304 samples: ranked
305 .into_iter()
306 .take(opts.max_samples)
307 .map(|(k, _)| sanitize(&k))
308 .collect(),
309 });
310 }
311 } else {
312 truncated = true;
313 }
314
315 if !spent(deadline) {
317 let mut owned: BTreeMap<String, usize> = BTreeMap::new();
318 for (_file, author, _w) in db.weighted_edges("TOP_AUTHOR", None) {
319 *owned.entry(author).or_default() += 1;
320 }
321 let mut named: Vec<(String, usize)> = owned
322 .into_iter()
323 .map(|(key, n)| {
324 let name = str_prop(db, &key, "name").unwrap_or(key);
327 (sanitize(&name), n)
328 })
329 .collect();
330 rank(&mut named);
331 named.truncate(MAX_OWNERS);
332 map.owners = named;
333 } else {
334 truncated = true;
335 }
336
337 if let (Some(now), false) = (now, spent(deadline)) {
339 let cutoff = now.saturating_sub(opts.hot_days.saturating_mul(SECS_PER_DAY));
340 let recent: BTreeSet<&str> = commit_ts
343 .iter()
344 .filter(|(_, ts)| (cutoff..=now).contains(ts))
345 .map(|(sha, _)| sha.as_str())
346 .collect();
347 let is_file: BTreeSet<&str> = file_keys.iter().map(String::as_str).collect();
348 let mut touched: BTreeMap<String, usize> = BTreeMap::new();
349 for (commit, file, _w) in db.weighted_edges("TOUCHED", None) {
350 if recent.contains(commit.as_str()) && is_file.contains(file.as_str()) {
351 *touched.entry(file).or_default() += 1;
352 }
353 }
354 let mut hot: Vec<(String, usize)> = touched
355 .into_iter()
356 .map(|(k, n)| (sanitize(&k), n))
357 .collect();
358 rank(&mut hot);
359 hot.truncate(MAX_HOT);
360 map.hot_files = hot;
361 } else if now.is_some() {
362 truncated = true;
363 }
364
365 if !spent(deadline) {
367 map.stale_concepts = super::concepts::stale_concepts(db).len();
368 } else {
369 truncated = true;
370 }
371
372 map.questions = questions(db, &map, &scores);
375 map.truncated = truncated;
376 map
377}
378
379pub(super) fn file_pagerank<F: Fs>(
394 db: &GraphDb<F>,
395 file_keys: &[String],
396 deadline: Option<Instant>,
397) -> (Vec<(String, f64)>, bool) {
398 let n = file_keys.len();
399 if n == 0 {
400 return (Vec::new(), false);
401 }
402 let idx: BTreeMap<&str, usize> = file_keys
403 .iter()
404 .enumerate()
405 .map(|(i, k)| (k.as_str(), i))
406 .collect();
407
408 let mut sym_file: BTreeMap<String, String> = BTreeMap::new();
410 for node in db.nodes_with_label("Symbol") {
411 if let Some(Value::Str(file)) = node.prop("file_id") {
412 sym_file.insert(node.key().to_string(), file);
413 }
414 }
415
416 let mut weight: BTreeMap<(usize, usize), f64> = BTreeMap::new();
417 let mut add = |src: Option<&usize>, dst: Option<&usize>, w: f64| {
418 if let (Some(&a), Some(&b)) = (src, dst) {
419 if a != b {
420 *weight.entry((a, b)).or_default() += w;
421 }
422 }
423 };
424 for (src, dst, _) in db.weighted_edges("IMPORTS", None) {
425 add(idx.get(src.as_str()), idx.get(dst.as_str()), 1.0);
426 }
427 for (src, dst, w) in db.weighted_edges("CO_CHANGED", Some("score")) {
428 add(
429 idx.get(src.as_str()),
430 idx.get(dst.as_str()),
431 w.unwrap_or(1.0),
432 );
433 }
434 for (src, dst, _) in db.weighted_edges("CALLS", None) {
435 let (Some(sf), Some(df)) = (sym_file.get(&src), sym_file.get(&dst)) else {
436 continue;
437 };
438 add(idx.get(sf.as_str()), idx.get(df.as_str()), 1.0);
439 }
440
441 let mut send_to: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
442 for ((a, b), w) in weight {
443 send_to[a].push((b, w));
444 }
445 let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
446 let mut dangling: Vec<usize> = Vec::new();
447 for (i, send) in send_to.iter().enumerate() {
448 let out: f64 = send.iter().map(|(_, w)| w).sum();
449 if send.is_empty() || out <= 0.0 {
450 dangling.push(i);
451 continue;
452 }
453 for &(j, w) in send {
454 receive_from[j].push((i, w / out));
455 }
456 }
457
458 let (pr, hit_budget) = power_iteration(n, &receive_from, &dangling, deadline);
459 let mut scores: Vec<(String, f64)> = file_keys.iter().cloned().zip(pr).collect();
460 rank(&mut scores);
461 (scores, hit_budget)
462}
463
464fn power_iteration(
473 n: usize,
474 receive_from: &[Vec<(usize, f64)>],
475 dangling: &[usize],
476 deadline: Option<Instant>,
477) -> (Vec<f64>, bool) {
478 let nf = n as f64;
479 let teleport = (1.0 - DAMPING) / nf;
480 let mut pr: Vec<f64> = vec![1.0 / nf; n];
481 for _ in 0..MAX_ITERS {
482 if spent(deadline) {
483 return (pr, true);
484 }
485 let leaked = dangling.iter().map(|&i| pr[i]).sum::<f64>() * DAMPING / nf;
486 let mut next = vec![teleport + leaked; n];
487 for (j, slot) in next.iter_mut().enumerate() {
488 *slot += DAMPING * receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum::<f64>();
489 }
490 let delta: f64 = pr.iter().zip(next.iter()).map(|(a, b)| (a - b).abs()).sum();
491 pr = next;
492 if delta < TOL {
493 break;
494 }
495 }
496 (pr, false)
497}
498
499fn questions<F: Fs>(db: &GraphDb<F>, map: &RepoMap, ranked: &[(String, f64)]) -> Vec<String> {
509 let mut out = Vec::new();
510 if let Some((first, _)) = ranked.first() {
511 let mut partners: Vec<(String, f64)> = db
514 .weighted_edges("CO_CHANGED", Some("score"))
515 .into_iter()
516 .filter(|(src, _, _)| src == first)
517 .map(|(_, dst, w)| (dst, w.unwrap_or(1.0)))
518 .collect();
519 rank(&mut partners);
520 if let Some((partner, _)) = partners.first() {
521 let a = basename(first);
522 let b = if basename(partner) == a {
525 partner.as_str()
526 } else {
527 basename(partner)
528 };
529 out.push(sanitize(&format!("why does {a} co-change with {b}?")));
530 }
531 }
532 if let Some(cluster) = map.communities.iter().find(|c| !c.dir.is_empty()) {
536 out.push(sanitize(&format!("who owns {}?", cluster.dir)));
537 }
538 if let Some((second, _)) = ranked.get(1) {
539 out.push(sanitize(&format!("what imports {}?", basename(second))));
540 }
541 out
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 fn line() -> (Vec<Vec<(usize, f64)>>, Vec<usize>) {
550 let receive_from = vec![Vec::new(), vec![(0, 1.0)], vec![(1, 1.0)]];
551 (receive_from, vec![2])
552 }
553
554 #[test]
555 fn an_expired_deadline_stops_the_iteration_before_it_starts() {
556 let (receive_from, dangling) = line();
557 let expired = Some(Instant::now() - Duration::from_secs(1));
558 let (pr, hit) = power_iteration(3, &receive_from, &dangling, expired);
559 assert!(hit, "the budget must be reported as spent");
560 assert_eq!(
561 pr,
562 vec![1.0 / 3.0; 3],
563 "nothing ran, so the ranks are still uniform — a valid partial answer"
564 );
565 }
566
567 #[test]
568 fn without_a_deadline_the_iteration_converges_and_ranks_the_sink_top() {
569 let (receive_from, dangling) = line();
570 let (pr, hit) = power_iteration(3, &receive_from, &dangling, None);
571 assert!(!hit, "no budget means nothing was cut short");
572 assert!(
573 pr[2] > pr[1] && pr[1] > pr[0],
574 "rank flows along the line and pools at the end: {pr:?}"
575 );
576 let total: f64 = pr.iter().sum();
577 assert!((total - 1.0).abs() < 1e-6, "ranks sum to one, got {total}");
578 }
579
580 #[test]
581 fn a_deadline_still_ahead_lets_the_iteration_finish() {
582 let (receive_from, dangling) = line();
583 let ample = Some(Instant::now() + Duration::from_secs(60));
584 let (pr, hit) = power_iteration(3, &receive_from, &dangling, ample);
585 assert!(!hit);
586 assert!(pr[2] > pr[0]);
587 }
588}