use crate::algo::LouvainConfig;
use crate::db::GraphDb;
use crate::repograph::facts::{rank, str_prop};
use crate::repograph::render::{basename, cluster_name, common_dir_prefix, sanitize};
use core_storage::fs::Fs;
use core_storage::Value;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::time::{Duration, Instant};
pub(super) const SYNC_KEY: &str = "__mushroomdb_git_sync__";
const SYNCED_AT: &str = "synced_at";
const CO_CHANGED_MIN_WEIGHT: f64 = 0.3;
const MAX_KEY_FILES: usize = 5;
const MAX_OWNERS: usize = 5;
const MAX_HOT: usize = 5;
const MIN_CLUSTER: usize = 2;
const DAMPING: f64 = 0.85;
const MAX_ITERS: u32 = 50;
const TOL: f64 = 1e-6;
const SECS_PER_DAY: i64 = 86_400;
#[derive(Debug, Clone, PartialEq)]
pub struct MapOptions {
pub max_communities: usize,
pub max_samples: usize,
pub hot_days: i64,
pub budget_ms: u64,
pub now_ts: Option<i64>,
}
impl Default for MapOptions {
fn default() -> Self {
Self {
max_communities: 8,
max_samples: 3,
hot_days: 90,
budget_ms: 3_000,
now_ts: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SyncInfo {
pub sha: String,
pub synced_at: Option<i64>,
pub age_secs: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct MapCommunity {
pub name: String,
pub dir: String,
pub size: usize,
pub cohesion: f64,
pub samples: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RepoMap {
pub files: usize,
pub symbols: usize,
pub commits: usize,
pub authors: usize,
pub last_sync: Option<SyncInfo>,
pub communities: Vec<MapCommunity>,
pub key_files: Vec<(String, f64)>,
pub owners: Vec<(String, usize)>,
pub hot_files: Vec<(String, usize)>,
pub hot_days: i64,
pub stale_concepts: usize,
pub questions: Vec<String>,
pub truncated: bool,
}
fn spent(deadline: Option<Instant>) -> bool {
deadline.is_some_and(|dl| Instant::now() >= dl)
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
fn remaining_ms(deadline: Option<Instant>) -> u64 {
match deadline {
None => 0,
Some(dl) => u64::try_from(dl.saturating_duration_since(Instant::now()).as_millis())
.unwrap_or(u64::MAX)
.max(1),
}
}
#[must_use]
pub fn repo_map<F: Fs>(db: &GraphDb<F>, opts: &MapOptions) -> RepoMap {
let deadline =
(opts.budget_ms > 0).then(|| Instant::now() + Duration::from_millis(opts.budget_ms));
let mut truncated = false;
let mut file_keys: Vec<String> = db
.nodes_with_label("File")
.iter()
.map(|n| n.key().to_string())
.collect();
file_keys.sort();
let files = file_keys.len();
let symbols = db.nodes_with_label("Symbol").len();
let authors = db.nodes_with_label("Author").len();
let mut commit_ts: BTreeMap<String, i64> = BTreeMap::new();
for n in db.nodes_with_label("Commit") {
if let Some(Value::Int(ts)) = n.prop("ts") {
commit_ts.insert(n.key().to_string(), ts);
}
}
let commits = db.nodes_with_label("Commit").len();
let now = opts.now_ts.or_else(|| commit_ts.values().copied().max());
let sync_now = opts.now_ts.unwrap_or_else(now_unix);
let mut map = RepoMap {
files,
symbols,
commits,
authors,
last_sync: None,
communities: Vec::new(),
key_files: Vec::new(),
owners: Vec::new(),
hot_files: Vec::new(),
hot_days: opts.hot_days,
stale_concepts: 0,
questions: Vec::new(),
truncated: false,
};
if files == 0 {
return map; }
map.last_sync = str_prop(db, SYNC_KEY, "sha").map(|sha| {
let synced_at = match db.node_ref(SYNC_KEY).and_then(|n| n.prop(SYNCED_AT)) {
Some(Value::Int(at)) => Some(at),
_ => None, };
SyncInfo {
sha: sanitize(&sha),
synced_at,
age_secs: synced_at.map(|at| sync_now - at),
}
});
let scores = if spent(deadline) {
truncated = true;
Vec::new()
} else {
let (scores, hit_budget) = file_pagerank(db, &file_keys, deadline);
truncated |= hit_budget;
scores
};
let by_score: BTreeMap<&str, f64> = scores.iter().map(|(k, s)| (k.as_str(), *s)).collect();
map.key_files = scores
.iter()
.take(MAX_KEY_FILES)
.map(|(k, s)| (sanitize(k), *s))
.collect();
if !truncated && !spent(deadline) {
let report = db.communities(&LouvainConfig {
edge_types: vec!["CO_CHANGED".to_string(), "IMPORTS".to_string()],
weight_prop: Some("score".to_string()),
min_weight: Some(CO_CHANGED_MIN_WEIGHT),
budget_ms: remaining_ms(deadline),
node_label: Some("File".to_string()),
..LouvainConfig::default()
});
truncated |= report.truncated;
for c in report
.communities
.iter()
.filter(|c| c.members.len() >= MIN_CLUSTER)
.take(opts.max_communities)
{
let mut ranked: Vec<(String, f64)> = c
.members
.iter()
.map(|k| (k.clone(), by_score.get(k.as_str()).copied().unwrap_or(0.0)))
.collect();
rank(&mut ranked);
map.communities.push(MapCommunity {
name: sanitize(&cluster_name(&c.members)),
dir: sanitize(&common_dir_prefix(&c.members)),
size: c.members.len(),
cohesion: c.cohesion,
samples: ranked
.into_iter()
.take(opts.max_samples)
.map(|(k, _)| sanitize(&k))
.collect(),
});
}
} else {
truncated = true;
}
if !spent(deadline) {
let mut owned: BTreeMap<String, usize> = BTreeMap::new();
for (_file, author, _w) in db.weighted_edges("TOP_AUTHOR", None) {
*owned.entry(author).or_default() += 1;
}
let mut named: Vec<(String, usize)> = owned
.into_iter()
.map(|(key, n)| {
let name = str_prop(db, &key, "name").unwrap_or(key);
(sanitize(&name), n)
})
.collect();
rank(&mut named);
named.truncate(MAX_OWNERS);
map.owners = named;
} else {
truncated = true;
}
if let (Some(now), false) = (now, spent(deadline)) {
let cutoff = now.saturating_sub(opts.hot_days.saturating_mul(SECS_PER_DAY));
let recent: BTreeSet<&str> = commit_ts
.iter()
.filter(|(_, ts)| (cutoff..=now).contains(ts))
.map(|(sha, _)| sha.as_str())
.collect();
let is_file: BTreeSet<&str> = file_keys.iter().map(String::as_str).collect();
let mut touched: BTreeMap<String, usize> = BTreeMap::new();
for (commit, file, _w) in db.weighted_edges("TOUCHED", None) {
if recent.contains(commit.as_str()) && is_file.contains(file.as_str()) {
*touched.entry(file).or_default() += 1;
}
}
let mut hot: Vec<(String, usize)> = touched
.into_iter()
.map(|(k, n)| (sanitize(&k), n))
.collect();
rank(&mut hot);
hot.truncate(MAX_HOT);
map.hot_files = hot;
} else if now.is_some() {
truncated = true;
}
if !spent(deadline) {
map.stale_concepts = super::concepts::stale_concepts(db).len();
} else {
truncated = true;
}
map.questions = questions(db, &map, &scores);
map.truncated = truncated;
map
}
fn file_pagerank<F: Fs>(
db: &GraphDb<F>,
file_keys: &[String],
deadline: Option<Instant>,
) -> (Vec<(String, f64)>, bool) {
let n = file_keys.len();
if n == 0 {
return (Vec::new(), false);
}
let idx: BTreeMap<&str, usize> = file_keys
.iter()
.enumerate()
.map(|(i, k)| (k.as_str(), i))
.collect();
let mut sym_file: BTreeMap<String, String> = BTreeMap::new();
for node in db.nodes_with_label("Symbol") {
if let Some(Value::Str(file)) = node.prop("file_id") {
sym_file.insert(node.key().to_string(), file);
}
}
let mut weight: BTreeMap<(usize, usize), f64> = BTreeMap::new();
let mut add = |src: Option<&usize>, dst: Option<&usize>, w: f64| {
if let (Some(&a), Some(&b)) = (src, dst) {
if a != b {
*weight.entry((a, b)).or_default() += w;
}
}
};
for (src, dst, _) in db.weighted_edges("IMPORTS", None) {
add(idx.get(src.as_str()), idx.get(dst.as_str()), 1.0);
}
for (src, dst, w) in db.weighted_edges("CO_CHANGED", Some("score")) {
add(
idx.get(src.as_str()),
idx.get(dst.as_str()),
w.unwrap_or(1.0),
);
}
for (src, dst, _) in db.weighted_edges("CALLS", None) {
let (Some(sf), Some(df)) = (sym_file.get(&src), sym_file.get(&dst)) else {
continue;
};
add(idx.get(sf.as_str()), idx.get(df.as_str()), 1.0);
}
let mut send_to: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
for ((a, b), w) in weight {
send_to[a].push((b, w));
}
let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
let mut dangling: Vec<usize> = Vec::new();
for (i, send) in send_to.iter().enumerate() {
let out: f64 = send.iter().map(|(_, w)| w).sum();
if send.is_empty() || out <= 0.0 {
dangling.push(i);
continue;
}
for &(j, w) in send {
receive_from[j].push((i, w / out));
}
}
let (pr, hit_budget) = power_iteration(n, &receive_from, &dangling, deadline);
let mut scores: Vec<(String, f64)> = file_keys.iter().cloned().zip(pr).collect();
rank(&mut scores);
(scores, hit_budget)
}
fn power_iteration(
n: usize,
receive_from: &[Vec<(usize, f64)>],
dangling: &[usize],
deadline: Option<Instant>,
) -> (Vec<f64>, bool) {
let nf = n as f64;
let teleport = (1.0 - DAMPING) / nf;
let mut pr: Vec<f64> = vec![1.0 / nf; n];
for _ in 0..MAX_ITERS {
if spent(deadline) {
return (pr, true);
}
let leaked = dangling.iter().map(|&i| pr[i]).sum::<f64>() * DAMPING / nf;
let mut next = vec![teleport + leaked; n];
for (j, slot) in next.iter_mut().enumerate() {
*slot += DAMPING * receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum::<f64>();
}
let delta: f64 = pr.iter().zip(next.iter()).map(|(a, b)| (a - b).abs()).sum();
pr = next;
if delta < TOL {
break;
}
}
(pr, false)
}
fn questions<F: Fs>(db: &GraphDb<F>, map: &RepoMap, ranked: &[(String, f64)]) -> Vec<String> {
let mut out = Vec::new();
if let Some((first, _)) = ranked.first() {
let mut partners: Vec<(String, f64)> = db
.weighted_edges("CO_CHANGED", Some("score"))
.into_iter()
.filter(|(src, _, _)| src == first)
.map(|(_, dst, w)| (dst, w.unwrap_or(1.0)))
.collect();
rank(&mut partners);
if let Some((partner, _)) = partners.first() {
let a = basename(first);
let b = if basename(partner) == a {
partner.as_str()
} else {
basename(partner)
};
out.push(sanitize(&format!("why does {a} co-change with {b}?")));
}
}
if let Some(cluster) = map.communities.iter().find(|c| !c.dir.is_empty()) {
out.push(sanitize(&format!("who owns {}?", cluster.dir)));
}
if let Some((second, _)) = ranked.get(1) {
out.push(sanitize(&format!("what imports {}?", basename(second))));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn line() -> (Vec<Vec<(usize, f64)>>, Vec<usize>) {
let receive_from = vec![Vec::new(), vec![(0, 1.0)], vec![(1, 1.0)]];
(receive_from, vec![2])
}
#[test]
fn an_expired_deadline_stops_the_iteration_before_it_starts() {
let (receive_from, dangling) = line();
let expired = Some(Instant::now() - Duration::from_secs(1));
let (pr, hit) = power_iteration(3, &receive_from, &dangling, expired);
assert!(hit, "the budget must be reported as spent");
assert_eq!(
pr,
vec![1.0 / 3.0; 3],
"nothing ran, so the ranks are still uniform — a valid partial answer"
);
}
#[test]
fn without_a_deadline_the_iteration_converges_and_ranks_the_sink_top() {
let (receive_from, dangling) = line();
let (pr, hit) = power_iteration(3, &receive_from, &dangling, None);
assert!(!hit, "no budget means nothing was cut short");
assert!(
pr[2] > pr[1] && pr[1] > pr[0],
"rank flows along the line and pools at the end: {pr:?}"
);
let total: f64 = pr.iter().sum();
assert!((total - 1.0).abs() < 1e-6, "ranks sum to one, got {total}");
}
#[test]
fn a_deadline_still_ahead_lets_the_iteration_finish() {
let (receive_from, dangling) = line();
let ample = Some(Instant::now() + Duration::from_secs(60));
let (pr, hit) = power_iteration(3, &receive_from, &dangling, ample);
assert!(!hit);
assert!(pr[2] > pr[0]);
}
}