use std::collections::BTreeMap;
use crate::graph::unified::concurrent::GraphSnapshot;
use crate::graph::unified::edge::EdgeKind;
use crate::graph::unified::file::FileId;
use crate::graph::unified::node::NodeId;
use super::centrality::{HubMetric, HubOpts, KindMask, node_is_symbol, rank_hubs};
pub const ALGORITHM_VERSION: u32 = 1;
pub const DEFAULT_MAX_FILE_NODES: u64 = 50_000;
pub const DEFAULT_MAX_COARSENED_EDGES: u64 = 500_000;
pub const DEFAULT_MAX_WORK_UNITS: u128 = 50_000_000;
const WORK_UNIT_FACTOR: u128 = 64;
const MAX_PASSES_PER_LEVEL: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Resolution {
num: u64,
den: u64,
}
impl Resolution {
pub const ONE: Resolution = Resolution { num: 1, den: 1 };
#[must_use]
pub const fn new(num: u64, den: u64) -> Option<Self> {
if den == 0 {
return None;
}
Some(Self { num, den })
}
#[must_use]
pub const fn num(self) -> u64 {
self.num
}
#[must_use]
pub const fn den(self) -> u64 {
self.den
}
}
impl Default for Resolution {
fn default() -> Self {
Self::ONE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct CommunityGate {
pub max_file_nodes: u64,
pub max_coarsened_edges: u64,
pub max_work_units: u128,
}
impl Default for CommunityGate {
fn default() -> Self {
Self {
max_file_nodes: DEFAULT_MAX_FILE_NODES,
max_coarsened_edges: DEFAULT_MAX_COARSENED_EDGES,
max_work_units: DEFAULT_MAX_WORK_UNITS,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CommunityOpts {
pub top: usize,
pub resolution: Resolution,
pub gate: CommunityGate,
}
impl Default for CommunityOpts {
fn default() -> Self {
Self {
top: 10,
resolution: Resolution::ONE,
gate: CommunityGate::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Community {
pub representative: Option<NodeId>,
pub files: Vec<FileId>,
pub size: u32,
pub symbol_count: u32,
pub internal_edges: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct GateVerdict {
pub file_nodes: u64,
pub coarsened_edges: u64,
pub work_units: u128,
pub gate: CommunityGate,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum CommunityOutcome {
Partition(Vec<Community>),
TooLarge(GateVerdict),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CommunityReport {
pub outcome: CommunityOutcome,
pub resolution: Resolution,
pub algorithm_version: u32,
}
#[derive(Debug, Clone)]
struct WeightedGraph {
adj: Vec<Vec<(usize, u64)>>,
degree: Vec<u64>,
two_m: u128,
}
impl WeightedGraph {
fn node_count(&self) -> usize {
self.adj.len()
}
fn edge_count(&self) -> u64 {
let total: usize = self.adj.iter().map(Vec::len).sum();
(total / 2) as u64
}
}
fn coarsen_to_files(snapshot: &GraphSnapshot) -> (WeightedGraph, Vec<FileId>) {
let node_slots = snapshot.nodes().slot_count();
let mut file_of_node: Vec<Option<u32>> = vec![None; node_slots];
for (id, entry) in snapshot.iter_nodes() {
if let Some(slot) = file_of_node.get_mut(id.index() as usize) {
*slot = Some(entry.file.index());
}
}
let mut weights: BTreeMap<(u32, u32), u64> = BTreeMap::new();
let mut self_weights: BTreeMap<u32, u64> = BTreeMap::new();
for edge in snapshot.edges().all_live_forward_edges() {
if !matches!(edge.kind, EdgeKind::Calls { .. } | EdgeKind::References) {
continue;
}
let (Some(Some(fu)), Some(Some(fv))) = (
file_of_node.get(edge.source.index() as usize),
file_of_node.get(edge.target.index() as usize),
) else {
continue;
};
if fu == fv {
*self_weights.entry(*fu).or_insert(0) += 1;
continue;
}
let key = if fu < fv { (*fu, *fv) } else { (*fv, *fu) };
*weights.entry(key).or_insert(0) += 1;
}
let mut file_to_compact: BTreeMap<u32, usize> = BTreeMap::new();
for &(a, b) in weights.keys() {
let next = file_to_compact.len();
file_to_compact.entry(a).or_insert(next);
let next = file_to_compact.len();
file_to_compact.entry(b).or_insert(next);
}
let file_count = file_to_compact.len();
let mut compact_to_file: Vec<FileId> = vec![FileId::new(0); file_count];
for (&file_index, &compact) in &file_to_compact {
compact_to_file[compact] = FileId::new(file_index);
}
let mut adj: Vec<Vec<(usize, u64)>> = vec![Vec::new(); file_count];
let mut degree: Vec<u64> = vec![0u64; file_count];
for (&(a, b), &w) in &weights {
let ca = file_to_compact[&a];
let cb = file_to_compact[&b];
adj[ca].push((cb, w));
adj[cb].push((ca, w));
degree[ca] = degree[ca].saturating_add(w);
degree[cb] = degree[cb].saturating_add(w);
}
for (&file_index, &compact) in &file_to_compact {
if let Some(&s) = self_weights.get(&file_index) {
degree[compact] = degree[compact].saturating_add(s.saturating_mul(2));
}
}
for list in &mut adj {
list.sort_unstable_by_key(|&(n, _)| n);
}
let two_m: u128 = degree.iter().map(|&d| u128::from(d)).sum();
(WeightedGraph { adj, degree, two_m }, compact_to_file)
}
fn move_score(
kin: u128,
two_m: u128,
den: u128,
num: u128,
sum_tot_c: u128,
ki: u128,
) -> Option<i128> {
let pos = i128::try_from(kin.checked_mul(two_m)?.checked_mul(den)?).ok()?;
let neg = i128::try_from(num.checked_mul(sum_tot_c)?.checked_mul(ki)?).ok()?;
Some(pos - neg)
}
fn canonicalize(raw: &[usize]) -> Vec<usize> {
let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
let mut out = vec![0usize; raw.len()];
for (node, &label) in raw.iter().enumerate() {
let next = remap.len();
let canonical = *remap.entry(label).or_insert(next);
out[node] = canonical;
}
out
}
fn local_move(graph: &WeightedGraph, res: Resolution) -> Option<Vec<usize>> {
let n = graph.node_count();
let two_m = graph.two_m;
let num = u128::from(res.num());
let den = u128::from(res.den());
let mut comm: Vec<usize> = (0..n).collect();
let mut sum_tot: Vec<u128> = graph.degree.iter().map(|&d| u128::from(d)).collect();
for _pass in 0..MAX_PASSES_PER_LEVEL {
let mut moved = false;
for i in 0..n {
let ci = comm[i];
let ki = u128::from(graph.degree[i]);
sum_tot[ci] -= ki;
let mut kin: BTreeMap<usize, u128> = BTreeMap::new();
for &(j, w) in &graph.adj[i] {
if j == i {
continue;
}
*kin.entry(comm[j]).or_insert(0) += u128::from(w);
}
let mut best_comm = i;
let mut best_score: i128 = 0;
for (&c, &weight_in) in &kin {
let score = move_score(weight_in, two_m, den, num, sum_tot[c], ki)?;
if score > best_score {
best_score = score;
best_comm = c;
}
}
comm[i] = best_comm;
sum_tot[best_comm] += ki;
if best_comm != ci {
moved = true;
}
}
if !moved {
break;
}
}
Some(canonicalize(&comm))
}
fn aggregate(graph: &WeightedGraph, comm: &[usize], k: usize) -> WeightedGraph {
let mut degree = vec![0u64; k];
for (i, &c) in comm.iter().enumerate() {
degree[c] = degree[c].saturating_add(graph.degree[i]);
}
let mut weights: BTreeMap<(usize, usize), u64> = BTreeMap::new();
for i in 0..graph.node_count() {
for &(j, w) in &graph.adj[i] {
if i >= j {
continue;
}
let ci = comm[i];
let cj = comm[j];
if ci == cj {
continue; }
let key = if ci < cj { (ci, cj) } else { (cj, ci) };
*weights.entry(key).or_insert(0) += w;
}
}
let mut adj: Vec<Vec<(usize, u64)>> = vec![Vec::new(); k];
for (&(ca, cb), &w) in &weights {
adj[ca].push((cb, w));
adj[cb].push((ca, w));
}
for list in &mut adj {
list.sort_unstable_by_key(|&(n, _)| n);
}
let two_m: u128 = degree.iter().map(|&d| u128::from(d)).sum();
WeightedGraph { adj, degree, two_m }
}
fn louvain(base: WeightedGraph, res: Resolution) -> Option<Vec<usize>> {
let base_count = base.node_count();
if base_count == 0 {
return Some(Vec::new());
}
let mut current = base;
let mut base_to_level: Vec<usize> = (0..base_count).collect();
loop {
let comm = local_move(¤t, res)?;
let k = comm.iter().copied().max().map_or(0, |m| m + 1);
for x in &mut base_to_level {
*x = comm[*x];
}
if k >= current.node_count() {
break; }
current = aggregate(¤t, &comm, k);
}
Some(canonicalize(&base_to_level))
}
fn split_disconnected_communities(graph: &WeightedGraph, comm: &[usize]) -> Vec<usize> {
let n = graph.node_count();
let mut out = vec![usize::MAX; n];
let mut next_label = 0usize;
for start in 0..n {
if out[start] != usize::MAX {
continue;
}
let community = comm[start];
let label = next_label;
next_label += 1;
let mut stack = vec![start];
out[start] = label;
while let Some(u) = stack.pop() {
for &(v, _w) in &graph.adj[u] {
if comm[v] == community && out[v] == usize::MAX {
out[v] = label;
stack.push(v);
}
}
}
}
out
}
fn internal_weights(graph: &WeightedGraph, comm: &[usize], community_count: usize) -> Vec<u64> {
let mut internal = vec![0u64; community_count];
for i in 0..graph.node_count() {
for &(j, w) in &graph.adj[i] {
if i < j && comm[i] == comm[j] {
internal[comm[i]] = internal[comm[i]].saturating_add(w);
}
}
}
internal
}
#[must_use]
pub fn detect_communities(snapshot: &GraphSnapshot, opts: &CommunityOpts) -> CommunityReport {
let (graph, compact_to_file) = coarsen_to_files(snapshot);
let file_nodes = graph.node_count() as u64;
let coarsened_edges = graph.edge_count();
let work_units =
(u128::from(file_nodes) + u128::from(coarsened_edges)).saturating_mul(WORK_UNIT_FACTOR);
let gate = opts.gate;
let over_gate = file_nodes > gate.max_file_nodes
|| coarsened_edges > gate.max_coarsened_edges
|| work_units > gate.max_work_units;
if over_gate {
return CommunityReport {
outcome: CommunityOutcome::TooLarge(GateVerdict {
file_nodes,
coarsened_edges,
work_units,
gate,
}),
resolution: opts.resolution,
algorithm_version: ALGORITHM_VERSION,
};
}
let Some(raw_comm) = louvain(graph.clone(), opts.resolution) else {
return CommunityReport {
outcome: CommunityOutcome::TooLarge(GateVerdict {
file_nodes,
coarsened_edges,
work_units,
gate,
}),
resolution: opts.resolution,
algorithm_version: ALGORITHM_VERSION,
};
};
let comm = split_disconnected_communities(&graph, &raw_comm);
let community_count = comm.iter().copied().max().map_or(0, |m| m + 1);
let internal = internal_weights(&graph, &comm, community_count);
let mut file_to_community: BTreeMap<u32, usize> = BTreeMap::new();
let mut members: Vec<Vec<FileId>> = vec![Vec::new(); community_count];
for (compact, &file) in compact_to_file.iter().enumerate() {
let label = comm[compact];
file_to_community.insert(file.index(), label);
members[label].push(file);
}
for list in &mut members {
list.sort_unstable_by_key(|f| f.index());
}
let mut symbol_count = vec![0u32; community_count];
for (_id, entry) in snapshot.iter_nodes() {
if !node_is_symbol(snapshot, entry) {
continue;
}
if let Some(&label) = file_to_community.get(&entry.file.index()) {
symbol_count[label] = symbol_count[label].saturating_add(1);
}
}
let hub_opts = HubOpts {
top: 0,
by: HubMetric::FanIn,
kinds: KindMask::default(),
};
let mut representative: Vec<Option<NodeId>> = vec![None; community_count];
for hub in rank_hubs(snapshot, &hub_opts) {
if let Some(entry) = snapshot.get_node(hub.node)
&& let Some(&label) = file_to_community.get(&entry.file.index())
&& representative[label].is_none()
{
representative[label] = Some(hub.node);
}
}
for (id, entry) in snapshot.iter_nodes() {
if !node_is_symbol(snapshot, entry) {
continue;
}
if let Some(&label) = file_to_community.get(&entry.file.index())
&& representative[label].is_none()
{
representative[label] = Some(id);
}
}
let mut communities: Vec<Community> = (0..community_count)
.map(|label| {
let files = std::mem::take(&mut members[label]);
let size = u32::try_from(files.len()).unwrap_or(u32::MAX);
Community {
representative: representative[label],
files,
size,
symbol_count: symbol_count[label],
internal_edges: internal[label],
}
})
.collect();
communities.sort_by(|a, b| {
b.symbol_count
.cmp(&a.symbol_count) .then_with(|| b.size.cmp(&a.size)) .then_with(|| {
let a_min = a.files.first().map_or(u32::MAX, |f| f.index());
let b_min = b.files.first().map_or(u32::MAX, |f| f.index());
a_min.cmp(&b_min)
})
});
if opts.top != 0 && communities.len() > opts.top {
communities.truncate(opts.top);
}
CommunityReport {
outcome: CommunityOutcome::Partition(communities),
resolution: opts.resolution,
algorithm_version: ALGORITHM_VERSION,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Language;
use crate::graph::unified::concurrent::CodeGraph;
use crate::graph::unified::edge::{EdgeKind, ResolvedVia};
use crate::graph::unified::node::NodeKind;
use crate::graph::unified::storage::arena::NodeEntry;
use std::collections::BTreeSet;
use std::path::PathBuf;
fn calls() -> EdgeKind {
EdgeKind::Calls {
argument_count: 0,
is_async: false,
resolved_via: ResolvedVia::Direct,
}
}
fn file_with_fn(graph: &mut CodeGraph, path: &str, name: &str) -> (FileId, NodeId) {
let file = graph
.files_mut()
.register_with_language(&PathBuf::from(path), Some(Language::Rust))
.expect("register file");
let sid = graph.strings_mut().intern(name).expect("intern name");
let entry = NodeEntry::new(NodeKind::Function, sid, file)
.with_definition(true)
.with_byte_range(0, 1);
let id = graph.nodes_mut().alloc(entry).expect("alloc node");
graph
.indices_mut()
.add(id, NodeKind::Function, sid, Some(sid), file);
(file, id)
}
fn edge(graph: &CodeGraph, from: NodeId, to: NodeId, file: FileId) {
graph.edges().add_edge(from, to, calls(), file);
}
fn build_three_clusters() -> (CodeGraph, Vec<BTreeSet<u32>>) {
let mut graph = CodeGraph::new();
let mut cluster_files: Vec<BTreeSet<u32>> = Vec::new();
let mut cluster_reps: Vec<NodeId> = Vec::new();
for c in 0..3 {
let mut files = Vec::new();
let mut fns = Vec::new();
for i in 0..3 {
let (f, n) = file_with_fn(
&mut graph,
&format!("cluster{c}/f{i}.rs"),
&format!("c{c}f{i}"),
);
files.push(f);
fns.push(n);
}
for &(a, b) in &[(0usize, 1usize), (1, 2), (0, 2)] {
edge(&graph, fns[a], fns[b], files[a]);
edge(&graph, fns[b], fns[a], files[b]);
}
cluster_files.push(files.iter().map(|f| f.index()).collect());
cluster_reps.push(fns[0]);
}
edge(&graph, cluster_reps[0], cluster_reps[1], FileId::new(0));
edge(&graph, cluster_reps[1], cluster_reps[2], FileId::new(0));
edge(&graph, cluster_reps[0], cluster_reps[2], FileId::new(0));
(graph, cluster_files)
}
fn partition(report: &CommunityReport) -> &[Community] {
match &report.outcome {
CommunityOutcome::Partition(v) => v,
CommunityOutcome::TooLarge(_) => panic!("expected a partition, got TooLarge"),
}
}
#[test]
fn three_dense_clusters_resolve_to_three_communities() {
let (graph, planted) = build_three_clusters();
let snapshot = graph.snapshot();
let report = detect_communities(&snapshot, &CommunityOpts::default());
let communities = partition(&report);
assert_eq!(communities.len(), 3, "one community per planted cluster");
let planted_sets: BTreeSet<BTreeSet<u32>> = planted.into_iter().collect();
let found_sets: BTreeSet<BTreeSet<u32>> = communities
.iter()
.map(|c| c.files.iter().map(|f| f.index()).collect())
.collect();
assert_eq!(
found_sets, planted_sets,
"communities match planted clusters"
);
for c in communities {
assert!(
c.representative.is_some(),
"community has a representative hub"
);
assert_eq!(c.size, 3);
assert_eq!(c.symbol_count, 3);
}
}
#[test]
fn partition_is_deterministic_across_runs() {
let (graph, _) = build_three_clusters();
let snapshot = graph.snapshot();
let first = detect_communities(&snapshot, &CommunityOpts::default());
for _ in 0..10 {
let again = detect_communities(&snapshot, &CommunityOpts::default());
assert_eq!(
again, first,
"exact-integer path is byte-stable across runs"
);
}
}
#[test]
fn connected_components_post_pass_splits_disconnected_community() {
let graph = WeightedGraph {
adj: vec![vec![(1, 1)], vec![(0, 1)], vec![(3, 1)], vec![(2, 1)]],
degree: vec![1, 1, 1, 1],
two_m: 4,
};
let split = split_disconnected_communities(&graph, &[0, 0, 0, 0]);
assert_eq!(split[0], split[1], "{{0,1}} stay together");
assert_eq!(split[2], split[3], "{{2,3}} stay together");
assert_ne!(
split[0], split[2],
"the disconnected halves are split apart"
);
let connected = WeightedGraph {
adj: vec![vec![(1, 1)], vec![(0, 1), (2, 1)], vec![(1, 1)]],
degree: vec![1, 2, 1],
two_m: 4,
};
let intact = split_disconnected_communities(&connected, &[0, 0, 0]);
assert_eq!(intact, vec![0, 0, 0], "a connected community is not split");
}
#[test]
fn higher_resolution_yields_more_communities() {
let (graph, _) = build_three_clusters();
let snapshot = graph.snapshot();
let low = detect_communities(
&snapshot,
&CommunityOpts {
top: 0,
resolution: Resolution::ONE,
gate: CommunityGate::default(),
},
);
let high = detect_communities(
&snapshot,
&CommunityOpts {
top: 0,
resolution: Resolution::new(1000, 1).unwrap(),
gate: CommunityGate::default(),
},
);
let low_n = partition(&low).len();
let high_n = partition(&high).len();
assert!(
high_n >= low_n,
"higher gamma yields at least as many (smaller) communities: {high_n} >= {low_n}"
);
assert!(high_n > low_n, "gamma 1000 fragments the clusters further");
}
#[test]
fn structural_gate_returns_too_large_deterministically() {
let (graph, _) = build_three_clusters();
let snapshot = graph.snapshot();
let opts = CommunityOpts {
top: 0,
resolution: Resolution::ONE,
gate: CommunityGate {
max_file_nodes: 1,
max_coarsened_edges: DEFAULT_MAX_COARSENED_EDGES,
max_work_units: DEFAULT_MAX_WORK_UNITS,
},
};
let report = detect_communities(&snapshot, &opts);
match report.outcome {
CommunityOutcome::TooLarge(verdict) => {
assert_eq!(verdict.file_nodes, 9);
assert!(verdict.file_nodes > verdict.gate.max_file_nodes);
}
CommunityOutcome::Partition(_) => panic!("expected TooLarge over the gate"),
}
assert_eq!(detect_communities(&snapshot, &opts), report);
}
#[test]
fn empty_graph_yields_empty_partition() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let report = detect_communities(&snapshot, &CommunityOpts::default());
assert!(partition(&report).is_empty());
}
#[test]
fn move_score_is_exact_integer() {
assert_eq!(move_score(2, 10, 1, 1, 4, 3), Some(8));
assert_eq!(move_score(2, 10, 2, 1, 4, 3), Some(28));
}
#[test]
fn intra_file_edges_fold_into_file_node_degree() {
let mut graph = CodeGraph::new();
let file_a = graph
.files_mut()
.register_with_language(&PathBuf::from("a.rs"), Some(Language::Rust))
.expect("register a");
let file_b = graph
.files_mut()
.register_with_language(&PathBuf::from("b.rs"), Some(Language::Rust))
.expect("register b");
let mk = |graph: &mut CodeGraph, name: &str, file: FileId| {
let sid = graph.strings_mut().intern(name).expect("intern");
let entry = NodeEntry::new(NodeKind::Function, sid, file).with_definition(true);
graph.nodes_mut().alloc(entry).expect("alloc")
};
let a1 = mk(&mut graph, "a1", file_a);
let a2 = mk(&mut graph, "a2", file_a);
let b1 = mk(&mut graph, "b1", file_b);
graph.edges().add_edge(a1, a2, calls(), file_a); graph.edges().add_edge(a1, b1, calls(), file_a); let snapshot = graph.snapshot();
let (wg, files) = coarsen_to_files(&snapshot);
assert_eq!(wg.node_count(), 2, "both files participate");
let idx_a = files.iter().position(|f| *f == file_a).expect("A present");
let idx_b = files.iter().position(|f| *f == file_b).expect("B present");
assert_eq!(wg.degree[idx_a], 3, "A degree = 1 cross + 2 self-loop");
assert_eq!(wg.degree[idx_b], 1, "B degree = 1 cross");
assert_eq!(wg.two_m, 4, "self-loop must count in 2m");
}
}