use crate::storage::capability::Storage;
use crate::storage::error::{Result as StorageResult, StorageError};
use crate::storage::schema::escape_cypher_string;
use petgraph::graph::{NodeIndex, UnGraph};
use petgraph::visit::EdgeRef;
use serde::Serialize;
use std::cell::RefCell;
const DEFAULT_RESOLUTION: f64 = 1.0;
const MAX_ROUNDS: usize = 20;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Community {
pub id: usize,
pub members: Vec<String>,
pub modularity: f64,
pub size: usize,
}
pub struct CommunityDetector<'a> {
storage: &'a dyn Storage,
project: String,
resolution: f64,
cache: RefCell<Option<Vec<Community>>>,
}
impl<'a> CommunityDetector<'a> {
#[must_use]
pub fn new(storage: &'a dyn Storage, project: impl Into<String>) -> Self {
Self {
storage,
project: project.into(),
resolution: DEFAULT_RESOLUTION,
cache: RefCell::new(None),
}
}
#[must_use]
pub fn with_resolution(mut self, resolution: f64) -> Self {
self.resolution = if resolution > 0.0 {
resolution
} else {
DEFAULT_RESOLUTION
};
self
}
#[must_use]
pub fn resolution(&self) -> f64 {
self.resolution
}
pub fn detect_communities(&self) -> StorageResult<Vec<Community>> {
let graph = self.load_calls_graph()?;
let communities = leiden(&graph, self.resolution);
let result = build_community_list(&graph, &communities);
*self.cache.borrow_mut() = Some(result.clone());
Ok(result)
}
pub fn community_members(&self, community_id: usize) -> StorageResult<Vec<String>> {
let cache = self.cache.borrow();
let cached = cache
.as_ref()
.ok_or_else(|| StorageError::NotFound("detect_communities not called".into()))?;
let community = cached
.iter()
.find(|c| c.id == community_id)
.ok_or_else(|| {
StorageError::NotFound(format!("community id {community_id} not found"))
})?;
Ok(community.members.clone())
}
fn load_calls_graph(&self) -> StorageResult<UnGraph<String, f64>> {
let escaped = escape_cypher_string(&self.project);
let cypher = format!(
"MATCH (e:CodeRelation) WHERE e.type = 'CALLS' AND e.project = '{escaped}' \
RETURN e.source AS source, e.target AS target;"
);
let rows = self.storage.query(&cypher)?;
use std::collections::{BTreeSet, HashMap};
let mut weights: HashMap<(String, String), f64> = HashMap::new();
let mut node_set: BTreeSet<String> = BTreeSet::new();
for row in rows {
if row.len() < 2 {
continue;
}
let src = row[0].as_str().unwrap_or_default().to_string();
let dst = row[1].as_str().unwrap_or_default().to_string();
if src.is_empty() || dst.is_empty() {
continue;
}
node_set.insert(src.clone());
node_set.insert(dst.clone());
let key = if src <= dst { (src, dst) } else { (dst, src) };
*weights.entry(key).or_insert(0.0) += 1.0;
}
let mut graph = UnGraph::<String, f64>::new_undirected();
let mut idx: HashMap<String, NodeIndex> = HashMap::new();
for name in &node_set {
idx.insert(name.clone(), graph.add_node(name.clone()));
}
for ((a, b), w) in &weights {
if let (Some(&ia), Some(&ib)) = (idx.get(a), idx.get(b)) {
graph.update_edge(ia, ib, *w);
}
}
Ok(graph)
}
}
fn leiden(graph: &UnGraph<String, f64>, resolution: f64) -> Vec<usize> {
modularity_core(graph, resolution, RefineMode::RefineConnected)
}
#[cfg(test)]
fn is_connected_subgraph(graph: &UnGraph<String, f64>, nodes: &[NodeIndex]) -> bool {
if nodes.is_empty() {
return true;
}
let n = graph.node_count();
let mut in_set: Vec<bool> = vec![false; n];
for &node in nodes {
in_set[node.index()] = true;
}
let mut visited: Vec<bool> = vec![false; n];
let mut stack: Vec<NodeIndex> = Vec::with_capacity(nodes.len());
stack.push(nodes[0]);
let mut visited_count = 0usize;
while let Some(node) = stack.pop() {
if visited[node.index()] {
continue;
}
visited[node.index()] = true;
visited_count += 1;
for edge in graph.edges(node) {
let other = if edge.source() == node {
edge.target()
} else {
edge.source()
};
if in_set[other.index()] && !visited[other.index()] {
stack.push(other);
}
}
}
visited_count == nodes.len()
}
#[cfg(test)]
fn louvain(graph: &UnGraph<String, f64>, resolution: f64) -> Vec<usize> {
modularity_core(graph, resolution, RefineMode::Plain)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefineMode {
#[cfg(test)]
Plain,
RefineConnected,
}
fn modularity_core(graph: &UnGraph<String, f64>, resolution: f64, mode: RefineMode) -> Vec<usize> {
let n = graph.node_count();
if n == 0 {
return Vec::new();
}
let mut partition: Vec<usize> = (0..n).collect();
let mut work: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let mut work_nodes: Vec<NodeIndex> = Vec::with_capacity(n);
for _ in 0..n {
work_nodes.push(work.add_node(()));
}
for edge in graph.raw_edges() {
let a = edge.source();
let b = edge.target();
let w = edge.weight;
work.update_edge(work_nodes[a.index()], work_nodes[b.index()], w);
}
let mut node_to_comm: Vec<usize> = (0..work.node_count()).collect();
for round in 0..MAX_ROUNDS {
let total_weight: f64 = work.raw_edges().iter().map(|e| e.weight).sum::<f64>();
let m2 = 2.0 * total_weight;
if m2 <= 0.0 {
break; }
let num_work_nodes = work.node_count();
let degrees: Vec<f64> = (0..num_work_nodes)
.map(|i| {
let ni = NodeIndex::new(i);
work.edges(ni)
.map(|e| {
let w = *e.weight();
if e.source() == e.target() {
w * 2.0
} else {
w
}
})
.sum::<f64>()
})
.collect();
let mut comm_tot: Vec<f64> = vec![0.0; num_work_nodes];
for (idx, &c) in node_to_comm.iter().enumerate() {
comm_tot[c] += degrees[idx];
}
let mut improved = true;
while improved {
improved = false;
for v_idx in 0..num_work_nodes {
let v = NodeIndex::new(v_idx);
let v_comm = node_to_comm[v_idx];
use std::collections::HashMap;
let mut comm_weights: HashMap<usize, f64> = HashMap::new();
for edge in work.edges(v) {
let other = if edge.source() == v {
edge.target()
} else {
edge.source()
};
if other == v {
continue; }
let c = node_to_comm[other.index()];
*comm_weights.entry(c).or_insert(0.0) += *edge.weight();
}
let cur_comm_tot = comm_tot[v_comm];
let k_v = degrees[v_idx];
let k_v_in_cur = comm_weights.get(&v_comm).copied().unwrap_or(0.0);
let cur_comm_tot_after = cur_comm_tot - k_v;
let mut best_comm = v_comm;
let mut best_gain = k_v_in_cur - resolution * k_v * cur_comm_tot_after / m2;
for (&c, &k_vc) in &comm_weights {
if c == v_comm {
continue; }
let c_tot = comm_tot[c];
let gain = k_vc - resolution * k_v * c_tot / m2;
if gain > best_gain + f64::EPSILON {
best_gain = gain;
best_comm = c;
}
}
if best_comm != v_comm {
comm_tot[v_comm] -= k_v;
comm_tot[best_comm] += k_v;
node_to_comm[v_idx] = best_comm;
improved = true;
}
}
}
if mode == RefineMode::RefineConnected {
node_to_comm = refine_partition_connected(&work, node_to_comm);
}
let work_comm = renumber_communities(&node_to_comm);
if round == 0 {
partition[..n].copy_from_slice(&work_comm[..n]);
} else {
for slot in partition.iter_mut().take(n) {
let prev_super = *slot;
*slot = work_comm[prev_super];
}
}
let num_comms = work_comm
.iter()
.copied()
.max()
.unwrap_or(0)
.saturating_add(1);
if num_comms >= work.node_count() {
break;
}
let mut next: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let next_nodes: Vec<NodeIndex> = (0..num_comms).map(|_| next.add_node(())).collect();
let mut self_loops: Vec<f64> = vec![0.0; num_comms];
use std::collections::HashMap as HM2;
let mut inter: HM2<(usize, usize), f64> = HM2::new();
for edge in work.raw_edges() {
let a = work_comm[edge.source().index()];
let b = work_comm[edge.target().index()];
let w = edge.weight;
if a == b {
self_loops[a] += w;
} else {
let key = if a < b { (a, b) } else { (b, a) };
*inter.entry(key).or_insert(0.0) += w;
}
}
for (c, w) in self_loops.iter().enumerate() {
if *w > 0.0 {
next.update_edge(next_nodes[c], next_nodes[c], *w);
}
}
for ((a, b), w) in inter {
next.update_edge(next_nodes[a], next_nodes[b], w);
}
work = next;
node_to_comm = (0..work.node_count()).collect();
}
renumber_communities(&partition)
}
fn refine_partition_connected(
graph: &petgraph::Graph<(), f64, petgraph::Undirected>,
mut partition: Vec<usize>,
) -> Vec<usize> {
let n = graph.node_count();
if n == 0 || partition.len() != n {
return partition;
}
use std::collections::BTreeMap;
let mut groups: BTreeMap<usize, Vec<NodeIndex>> = BTreeMap::new();
for (idx, &c) in partition.iter().enumerate() {
groups.entry(c).or_default().push(NodeIndex::new(idx));
}
let mut next_comm = partition
.iter()
.copied()
.max()
.unwrap_or(0)
.saturating_add(1);
let mut in_current_comm: Vec<bool> = vec![false; n];
let mut visited: Vec<bool> = vec![false; n];
let mut stack: Vec<NodeIndex> = Vec::with_capacity(n);
for (_orig_comm, members) in groups {
if members.len() <= 1 {
continue;
}
for &m in &members {
in_current_comm[m.index()] = true;
}
let mut components: Vec<Vec<NodeIndex>> = Vec::new();
for &start in &members {
if visited[start.index()] {
continue;
}
let mut comp: Vec<NodeIndex> = Vec::new();
stack.clear();
stack.push(start);
while let Some(node) = stack.pop() {
if visited[node.index()] {
continue;
}
visited[node.index()] = true;
comp.push(node);
for edge in graph.edges(node) {
let other = if edge.source() == node {
edge.target()
} else {
edge.source()
};
if in_current_comm[other.index()] && !visited[other.index()] {
stack.push(other);
}
}
}
components.push(comp);
}
for (i, comp) in components.iter().enumerate() {
if i == 0 {
continue;
}
for &node in comp {
partition[node.index()] = next_comm;
}
next_comm = next_comm.saturating_add(1);
}
for &m in &members {
in_current_comm[m.index()] = false;
visited[m.index()] = false;
}
}
partition
}
fn renumber_communities(partition: &[usize]) -> Vec<usize> {
use std::collections::HashMap;
let mut remap: HashMap<usize, usize> = HashMap::new();
let mut next_id = 0usize;
let mut out = Vec::with_capacity(partition.len());
for &c in partition {
let id = *remap.entry(c).or_insert_with(|| {
let id = next_id;
next_id += 1;
id
});
out.push(id);
}
out
}
#[cfg(test)]
fn compute_modularity(graph: &UnGraph<String, f64>, communities: &[usize], resolution: f64) -> f64 {
let n = graph.node_count();
if n == 0 || communities.len() != n {
return 0.0;
}
let mut degrees = vec![0.0_f64; n];
let mut total_weight = 0.0_f64;
for edge in graph.raw_edges() {
let w = edge.weight;
let a = edge.source().index();
let b = edge.target().index();
degrees[a] += w;
if a == b {
degrees[a] += w;
total_weight += w;
} else {
degrees[b] += w;
total_weight += w;
}
}
let m = total_weight;
if m <= 0.0 {
return 0.0;
}
let m2 = 2.0 * m;
use std::collections::HashMap;
let mut sigma_in: HashMap<usize, f64> = HashMap::new();
let mut sigma_tot: HashMap<usize, f64> = HashMap::new();
for (idx, &c) in communities.iter().enumerate() {
*sigma_tot.entry(c).or_insert(0.0) += degrees[idx];
}
for edge in graph.raw_edges() {
let a = edge.source().index();
let b = edge.target().index();
if communities[a] == communities[b] {
if a == b {
*sigma_in.entry(communities[a]).or_insert(0.0) += edge.weight;
} else {
*sigma_in.entry(communities[a]).or_insert(0.0) += 2.0 * edge.weight;
}
}
}
let mut q = 0.0_f64;
for c in sigma_tot.keys() {
let s_in = sigma_in.get(c).copied().unwrap_or(0.0);
let s_tot = sigma_tot.get(c).copied().unwrap_or(0.0);
q += s_in / m2 - resolution * (s_tot / m2).powi(2);
}
q
}
fn build_community_list(graph: &UnGraph<String, f64>, communities: &[usize]) -> Vec<Community> {
use std::collections::HashMap;
let n = graph.node_count();
if n == 0 || communities.len() != n {
return Vec::new();
}
let mut groups: HashMap<usize, Vec<NodeIndex>> = HashMap::new();
for (idx, &c) in communities.iter().enumerate() {
groups.entry(c).or_default().push(NodeIndex::new(idx));
}
let mut degrees = vec![0.0_f64; n];
let mut total_weight = 0.0_f64;
for edge in graph.raw_edges() {
let w = edge.weight;
let a = edge.source().index();
let b = edge.target().index();
degrees[a] += w;
if a == b {
degrees[a] += w;
total_weight += w;
} else {
degrees[b] += w;
total_weight += w;
}
}
let m = total_weight;
let m2 = 2.0 * m;
let mut sigma_in: HashMap<usize, f64> = HashMap::new();
let mut sigma_tot: HashMap<usize, f64> = HashMap::new();
for (idx, &c) in communities.iter().enumerate() {
*sigma_tot.entry(c).or_insert(0.0) += degrees[idx];
}
for edge in graph.raw_edges() {
let a = edge.source().index();
let b = edge.target().index();
if communities[a] == communities[b] {
if a == b {
*sigma_in.entry(communities[a]).or_insert(0.0) += edge.weight;
} else {
*sigma_in.entry(communities[a]).or_insert(0.0) += 2.0 * edge.weight;
}
}
}
let mut result: Vec<Community> = groups
.into_iter()
.map(|(id, mut members)| {
members.sort_by_key(|n| n.index());
let member_names: Vec<String> = members
.iter()
.map(|&n| graph.node_weight(n).cloned().unwrap_or_default())
.collect();
let s_in = sigma_in.get(&id).copied().unwrap_or(0.0);
let s_tot = sigma_tot.get(&id).copied().unwrap_or(0.0);
let q_c = if m2 > 0.0 {
s_in / m2 - (s_tot / m2).powi(2)
} else {
0.0
};
let size = member_names.len();
Community {
id,
members: member_names,
modularity: q_c,
size,
}
})
.collect();
result.sort_by(|a, b| {
b.size
.cmp(&a.size)
.then_with(|| a.members.first().cmp(&b.members.first()))
});
for (i, c) in result.iter_mut().enumerate() {
c.id = i;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kit::{build_kit, AsyncKit, AsyncReady, KitBootstrapConfig, StorageModule};
use petgraph::graph::UnGraph;
use tempfile::TempDir;
fn fresh_db_path() -> std::path::PathBuf {
let dir = TempDir::new().unwrap();
let path = dir.path().join("community_testdb");
std::mem::forget(dir);
path
}
fn build_kit_for_db(db: &std::path::Path) -> AsyncKit<AsyncReady> {
let config = KitBootstrapConfig::new(db.to_path_buf());
tokio::runtime::Runtime::new()
.unwrap()
.block_on(build_kit(&config))
.expect("build_kit")
}
fn storage(
kit: &AsyncKit<AsyncReady>,
) -> std::sync::Arc<dyn crate::storage::capability::Storage> {
kit.require::<StorageModule>().expect("require_storage")
}
fn create_function(kit: &AsyncKit<AsyncReady>, id: &str, project: &str, name: &str, qn: &str) {
let s = storage(kit);
let cypher = format!(
"CREATE (:Function {{id: '{}', project: '{}', name: '{}', qualifiedName: '{}', \
filePath: '/src/x.rs', startLine: 1, endLine: 5, signature: '', returnType: '', \
isExported: false, docstring: '', content: '', parentQn: ''}});",
escape_cypher_string(id),
escape_cypher_string(project),
escape_cypher_string(name),
escape_cypher_string(qn),
);
s.execute(&cypher).expect("create function");
}
fn create_calls_edge(
kit: &AsyncKit<AsyncReady>,
id: &str,
source: &str,
target: &str,
project: &str,
) {
let s = storage(kit);
let cypher = format!(
"CREATE (:CodeRelation {{id: '{}', source: '{}', target: '{}', type: 'CALLS', \
confidence: 1.0, confidenceTier: 'High', reason: '', startLine: 1, project: '{}'}});",
escape_cypher_string(id),
escape_cypher_string(source),
escape_cypher_string(target),
escape_cypher_string(project),
);
s.execute(&cypher).expect("create calls edge");
}
fn graph_from_edges(edges: &[(usize, usize)]) -> UnGraph<String, f64> {
let mut g = UnGraph::<String, f64>::new_undirected();
let max_node = edges.iter().flat_map(|&(a, b)| [a, b]).max().unwrap_or(0);
let mut nodes: Vec<NodeIndex> = Vec::with_capacity(max_node + 1);
for i in 0..=max_node {
nodes.push(g.add_node(format!("n{i}")));
}
for &(a, b) in edges {
g.update_edge(nodes[a], nodes[b], 1.0);
}
g
}
fn karate_club_graph() -> UnGraph<String, f64> {
let edges: &[(usize, usize)] = &[
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(0, 5),
(0, 6),
(0, 7),
(0, 8),
(0, 10),
(0, 11),
(0, 12),
(0, 13),
(0, 17),
(0, 19),
(0, 21),
(0, 31),
(1, 2),
(1, 3),
(1, 7),
(1, 13),
(1, 17),
(1, 19),
(1, 21),
(1, 30),
(2, 3),
(2, 7),
(2, 8),
(2, 9),
(2, 13),
(2, 27),
(2, 28),
(2, 32),
(3, 7),
(3, 12),
(3, 13),
(4, 6),
(4, 10),
(5, 6),
(5, 10),
(5, 16),
(6, 16),
(8, 30),
(8, 32),
(8, 33),
(9, 33),
(13, 33),
(14, 32),
(15, 32),
(15, 33),
(18, 32),
(18, 33),
(19, 33),
(20, 32),
(20, 33),
(22, 32),
(22, 33),
(23, 25),
(23, 27),
(23, 29),
(23, 32),
(23, 33),
(24, 25),
(24, 27),
(24, 31),
(25, 31),
(26, 29),
(26, 33),
(27, 33),
(28, 31),
(28, 33),
(29, 32),
(29, 33),
(30, 32),
(30, 33),
(31, 32),
(31, 33),
(32, 33),
];
graph_from_edges(edges)
}
#[test]
fn modularity_of_single_community_is_zero() {
let g = graph_from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
let communities = vec![0; g.node_count()];
let q = compute_modularity(&g, &communities, 1.0);
assert!(
q.abs() < 1e-9,
"single community on fully-connected graph → Q ≈ 0, got {q}"
);
}
#[test]
fn modularity_of_two_disconnected_cliques_is_high() {
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0..5 {
for j in (i + 1)..5 {
edges.push((i, j));
}
}
for i in 5..10 {
for j in (i + 1)..10 {
edges.push((i, j));
}
}
let g = graph_from_edges(&edges);
let communities: Vec<usize> = (0..10).map(|i| if i < 5 { 0 } else { 1 }).collect();
let q = compute_modularity(&g, &communities, 1.0);
assert!(q > 0.3, "two disconnected K5 cliques → Q > 0.3, got {q}");
}
#[test]
fn detect_finds_two_communities_in_disjoint_graph() {
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0..5 {
for j in (i + 1)..5 {
edges.push((i, j));
}
}
for i in 5..10 {
for j in (i + 1)..10 {
edges.push((i, j));
}
}
let g = graph_from_edges(&edges);
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert_eq!(list.len(), 2, "should find exactly 2 communities");
for c in &list {
assert_eq!(c.size, 5, "each community should have 5 members");
}
}
#[test]
fn detect_empty_graph_returns_zero_communities() {
let g = UnGraph::<String, f64>::new_undirected();
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert!(list.is_empty(), "empty graph → 0 communities");
}
#[test]
fn detect_single_node_graph_returns_one_community() {
let mut g = UnGraph::<String, f64>::new_undirected();
g.add_node("solo".to_string());
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert_eq!(list.len(), 1, "single node → 1 community");
assert_eq!(list[0].size, 1);
}
#[test]
fn detect_fully_connected_graph_returns_one_community() {
let g = graph_from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert_eq!(list.len(), 1, "fully connected → 1 community");
assert_eq!(list[0].size, 4);
}
#[test]
fn detect_resolution_affects_community_count() {
let g = karate_club_graph();
let low_res = louvain(&g, 0.5);
let high_res = louvain(&g, 2.5);
let low_count = build_community_list(&g, &low_res).len();
let high_count = build_community_list(&g, &high_res).len();
assert!(
high_count >= low_count,
"higher resolution should produce ≥ communities (low={low_count}, high={high_count})"
);
}
#[test]
fn detect_karate_club_finds_two_communities_gold_standard() {
let g = karate_club_graph();
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert!(
(2..=5).contains(&list.len()),
"Karate Club should split into 2 (±1) communities, got {}",
list.len()
);
let total: usize = list.iter().map(|c| c.size).sum();
assert_eq!(total, 34, "all 34 nodes should be assigned");
let mut sorted = list.clone();
sorted.sort_by_key(|b| std::cmp::Reverse(b.size));
assert!(!sorted.is_empty(), "at least one community expected");
}
#[test]
fn from_storage_loads_calls_edges() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f_a", "demo", "a", "demo.a");
create_function(&kit, "f_b", "demo", "b", "demo.b");
create_function(&kit, "f_c", "demo", "c", "demo.c");
create_calls_edge(&kit, "e1", "f_a", "f_b", "demo");
create_calls_edge(&kit, "e2", "f_b", "f_c", "demo");
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let communities = detector.detect_communities().expect("detect");
assert!(
communities.iter().any(|c| c.size == 3),
"3 connected nodes → 1 community of size 3, got {communities:?}"
);
}
#[test]
fn from_storage_empty_db_returns_no_communities() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let communities = detector.detect_communities().expect("detect");
assert!(communities.is_empty(), "empty DB → no communities");
}
#[test]
fn community_members_returns_cached_result() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f_a", "demo", "a", "demo.a");
create_function(&kit, "f_b", "demo", "b", "demo.b");
create_calls_edge(&kit, "e1", "f_a", "f_b", "demo");
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let communities = detector.detect_communities().expect("detect");
assert_eq!(communities.len(), 1, "single connected pair → 1 community");
let members = detector.community_members(0).expect("members");
assert_eq!(members.len(), 2, "community 0 has 2 members");
}
#[test]
fn community_members_without_detect_returns_error() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let err = detector.community_members(0).unwrap_err();
assert!(
matches!(err, crate::storage::StorageError::NotFound(_)),
"should return NotFound before detect_communities is called"
);
}
#[test]
fn community_members_invalid_id_returns_error() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
create_function(&kit, "f_a", "demo", "a", "demo.a");
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let _ = detector.detect_communities().expect("detect");
let err = detector.community_members(999).unwrap_err();
assert!(
matches!(err, crate::storage::StorageError::NotFound(_)),
"invalid community id → NotFound"
);
}
#[test]
fn with_resolution_builder_changes_behavior() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
let s = storage(&kit);
let default = CommunityDetector::new(&*s, "demo");
assert_eq!(default.resolution, 1.0);
let high = CommunityDetector::new(&*s, "demo").with_resolution(2.5);
assert!((high.resolution - 2.5).abs() < f64::EPSILON);
let bad = CommunityDetector::new(&*s, "demo").with_resolution(-1.0);
assert_eq!(bad.resolution, 1.0);
}
#[test]
fn compute_modularity_returns_zero_for_empty_graph() {
let g = UnGraph::<String, f64>::new_undirected();
let communities: Vec<usize> = Vec::new();
let q = compute_modularity(&g, &communities, 1.0);
assert!(q.abs() < 1e-9, "empty graph → Q = 0, got {q}");
}
#[test]
fn compute_modularity_returns_zero_for_mismatched_length() {
let g = graph_from_edges(&[(0, 1), (1, 2)]);
let communities = vec![0, 1]; let q = compute_modularity(&g, &communities, 1.0);
assert!(
q.abs() < 1e-9,
"mismatched communities length → Q = 0, got {q}"
);
}
#[test]
fn compute_modularity_returns_zero_for_no_edges() {
let mut g = UnGraph::<String, f64>::new_undirected();
g.add_node("a".to_string());
g.add_node("b".to_string());
let communities = vec![0, 0];
let q = compute_modularity(&g, &communities, 1.0);
assert!(q.abs() < 1e-9, "no edges → Q = 0, got {q}");
}
#[test]
fn compute_modularity_handles_self_loop() {
let mut g = UnGraph::<String, f64>::new_undirected();
let n0 = g.add_node("a".to_string());
let n1 = g.add_node("b".to_string());
g.update_edge(n0, n0, 2.0); g.update_edge(n0, n1, 1.0);
let communities = vec![0, 0];
let q = compute_modularity(&g, &communities, 1.0);
assert!(q.is_finite(), "Q should be finite, got {q}");
assert!(
q < 0.0,
"single community with self-loop → Q < 0 (A_ii=w convention), got {q}"
);
}
#[test]
fn compute_modularity_self_loop_in_split_community() {
let mut g = UnGraph::<String, f64>::new_undirected();
let n0 = g.add_node("a".to_string());
let n1 = g.add_node("b".to_string());
let n2 = g.add_node("c".to_string());
g.update_edge(n0, n0, 1.0); g.update_edge(n0, n1, 1.0); g.update_edge(n1, n2, 0.5); let communities = vec![0, 0, 1]; let q = compute_modularity(&g, &communities, 1.0);
assert!(q.is_finite(), "Q should be finite, got {q}");
assert!(
q < 0.0,
"self-loop + inter-comm edge → Q < 0 (A_ii=w convention), got {q}"
);
}
#[test]
fn build_community_list_handles_self_loops() {
let mut g = UnGraph::<String, f64>::new_undirected();
let n0 = g.add_node("a".to_string());
let n1 = g.add_node("b".to_string());
g.update_edge(n0, n0, 1.0); g.update_edge(n0, n1, 1.0);
let communities = vec![0, 0];
let list = build_community_list(&g, &communities);
assert_eq!(list.len(), 1, "single community expected");
assert_eq!(list[0].size, 2, "community has 2 members");
assert!(
list[0].modularity.is_finite(),
"modularity should be finite, got {}",
list[0].modularity
);
}
#[test]
fn build_community_list_returns_empty_for_mismatched_length() {
let g = graph_from_edges(&[(0, 1)]);
let communities = vec![0]; let list = build_community_list(&g, &communities);
assert!(
list.is_empty(),
"mismatched communities length → empty list"
);
}
#[test]
fn louvain_handles_graph_with_self_loops() {
let mut g = UnGraph::<String, f64>::new_undirected();
let n0 = g.add_node("a".to_string());
let n1 = g.add_node("b".to_string());
let n2 = g.add_node("c".to_string());
let n3 = g.add_node("d".to_string());
g.update_edge(n0, n0, 1.0); g.update_edge(n0, n1, 1.0);
g.update_edge(n2, n2, 1.0); g.update_edge(n2, n3, 1.0);
let assignment = louvain(&g, 1.0);
let list = build_community_list(&g, &assignment);
let total: usize = list.iter().map(|c| c.size).sum();
assert_eq!(total, 4, "all 4 nodes should be assigned");
}
#[test]
fn test_leiden_refinement_phase_produces_connected_communities() {
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0..5 {
for j in (i + 1)..5 {
edges.push((i, j));
}
}
for i in 5..10 {
for j in (i + 1)..10 {
edges.push((i, j));
}
}
edges.push((2, 10));
edges.push((10, 7));
let g = graph_from_edges(&edges);
let assignment = leiden(&g, 1.0);
use std::collections::HashMap;
let mut groups: HashMap<usize, Vec<NodeIndex>> = HashMap::new();
for (idx, &c) in assignment.iter().enumerate() {
groups.entry(c).or_default().push(NodeIndex::new(idx));
}
assert!(
!groups.is_empty(),
"Leiden should produce at least one community"
);
for (comm, members) in &groups {
assert!(
is_connected_subgraph(&g, members),
"Leiden produced disconnected community {comm} with members {members:?} — \
refinement phase should guarantee connectivity (C3)"
);
}
}
#[test]
fn test_leiden_preserves_louvain_quality_on_karate_club() {
let g = karate_club_graph();
let louvain_assignment = louvain(&g, 1.0);
let leiden_assignment = leiden(&g, 1.0);
let louvain_count = build_community_list(&g, &louvain_assignment).len();
let leiden_count = build_community_list(&g, &leiden_assignment).len();
assert!(
leiden_count >= louvain_count,
"Leiden should produce >= Louvain communities (L={louvain_count}, Ld={leiden_count})"
);
assert!(
leiden_count <= louvain_count + 2,
"Leiden should not over-split Karate Club (L={louvain_count}, Ld={leiden_count})"
);
use std::collections::HashMap;
let mut groups: HashMap<usize, Vec<NodeIndex>> = HashMap::new();
for (idx, &c) in leiden_assignment.iter().enumerate() {
groups.entry(c).or_default().push(NodeIndex::new(idx));
}
for (comm, members) in &groups {
assert!(
is_connected_subgraph(&g, members),
"Leiden produced disconnected community {comm} on Karate Club (C3)"
);
}
}
#[test]
fn test_refine_partition_connected_splits_disconnected_community() {
let mut work: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let n0 = work.add_node(());
let n1 = work.add_node(());
let n2 = work.add_node(());
let n3 = work.add_node(());
let n4 = work.add_node(());
let n5 = work.add_node(());
work.update_edge(n0, n1, 1.0);
work.update_edge(n1, n2, 1.0);
work.update_edge(n0, n2, 1.0);
work.update_edge(n3, n4, 1.0);
work.update_edge(n4, n5, 1.0);
work.update_edge(n3, n5, 1.0);
let partition = vec![0; 6];
let refined = refine_partition_connected(&work, partition);
let unique: std::collections::HashSet<usize> = refined.iter().copied().collect();
assert_eq!(
unique.len(),
2,
"disconnected community should be split into 2, got {refined:?}"
);
assert_eq!(refined[0], refined[1]);
assert_eq!(refined[1], refined[2]);
assert_eq!(refined[3], refined[4]);
assert_eq!(refined[4], refined[5]);
assert_ne!(refined[0], refined[3]);
}
#[test]
fn test_refine_partition_connected_preserves_connected_community() {
let mut work: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let n0 = work.add_node(());
let n1 = work.add_node(());
let n2 = work.add_node(());
work.update_edge(n0, n1, 1.0);
work.update_edge(n1, n2, 1.0);
let partition = vec![0; 3];
let refined = refine_partition_connected(&work, partition);
let unique: std::collections::HashSet<usize> = refined.iter().copied().collect();
assert_eq!(
unique.len(),
1,
"connected community should not be split, got {refined:?}"
);
}
#[test]
fn test_refine_partition_connected_empty_and_singletons() {
let empty: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let refined = refine_partition_connected(&empty, Vec::new());
assert!(refined.is_empty(), "empty graph → empty partition");
let mut work: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
let _n0 = work.add_node(());
let refined = refine_partition_connected(&work, vec![0]);
assert_eq!(refined, vec![0], "singleton community unchanged");
}
#[test]
fn test_leiden_empty_graph_returns_no_communities() {
let g = UnGraph::<String, f64>::new_undirected();
let assignment = leiden(&g, 1.0);
assert!(assignment.is_empty(), "empty graph → empty assignment");
assert!(build_community_list(&g, &assignment).is_empty());
}
#[test]
fn test_leiden_single_node_returns_one_community() {
let mut g = UnGraph::<String, f64>::new_undirected();
g.add_node("solo".to_string());
let assignment = leiden(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert_eq!(list.len(), 1, "single node → 1 community");
assert_eq!(list[0].size, 1);
}
#[test]
fn test_leiden_fully_connected_returns_one_community() {
let g = graph_from_edges(&[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
let assignment = leiden(&g, 1.0);
let list = build_community_list(&g, &assignment);
assert_eq!(list.len(), 1, "fully connected → 1 community");
assert_eq!(list[0].size, 4);
}
#[test]
fn test_leiden_handles_graph_with_self_loops() {
let mut g = UnGraph::<String, f64>::new_undirected();
let a = g.add_node("a".to_string());
let b = g.add_node("b".to_string());
let c = g.add_node("c".to_string());
g.update_edge(a, b, 1.0);
g.update_edge(b, c, 1.0);
g.update_edge(a, a, 2.0); let assignment = leiden(&g, 1.0);
let list = build_community_list(&g, &assignment);
let total: usize = list.iter().map(|c| c.size).sum();
assert_eq!(total, 3, "all 3 nodes should be assigned");
}
#[test]
fn test_leiden_resolution_affects_community_count() {
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0..4 {
for j in (i + 1)..4 {
edges.push((i, j));
}
}
for i in 4..8 {
for j in (i + 1)..8 {
edges.push((i, j));
}
}
edges.push((0, 4));
let g = graph_from_edges(&edges);
let low = build_community_list(&g, &leiden(&g, 1.0)).len();
let high = build_community_list(&g, &leiden(&g, 10.0)).len();
assert!(
high >= low,
"higher resolution should produce >= communities (low={low}, high={high})"
);
assert!(high >= 2, "at high resolution the two cliques should split");
}
#[test]
fn test_refine_partition_connected_mixed_input() {
let mut work: petgraph::Graph<(), f64, petgraph::Undirected> =
petgraph::Graph::new_undirected();
for _ in 0..8 {
work.add_node(());
}
work.update_edge(NodeIndex::new(0), NodeIndex::new(1), 1.0);
work.update_edge(NodeIndex::new(1), NodeIndex::new(2), 1.0);
work.update_edge(NodeIndex::new(0), NodeIndex::new(2), 1.0);
work.update_edge(NodeIndex::new(3), NodeIndex::new(4), 1.0);
work.update_edge(NodeIndex::new(5), NodeIndex::new(6), 1.0);
let partition = vec![0, 0, 0, 1, 1, 1, 1, 2];
let refined = refine_partition_connected(&work, partition);
assert_eq!(refined[0], 0, "comm 0 first node keeps id");
assert_eq!(refined[1], 0, "comm 0 second node keeps id");
assert_eq!(refined[2], 0, "comm 0 third node keeps id");
assert_eq!(refined[3], 1, "comm 1 first component keeps id");
assert_eq!(refined[4], 1, "comm 1 first component keeps id");
assert_eq!(
refined[5], refined[6],
"comm 1 second component shares new id"
);
assert_ne!(refined[5], 1, "comm 1 second component gets a new id");
assert_ne!(refined[5], 0, "new id must not collide with comm 0");
assert_eq!(refined[7], 2, "comm 2 singleton keeps id");
let unique: std::collections::HashSet<usize> = refined.iter().copied().collect();
assert_eq!(
unique.len(),
4,
"expected 4 communities after mixed refinement, got {refined:?}"
);
}
#[test]
fn test_detect_communities_leiden_connectivity_invariant() {
let db = fresh_db_path();
let kit = build_kit_for_db(&db);
for i in 0..5 {
create_function(
&kit,
&format!("f_a{i}"),
"demo",
&format!("a{i}"),
&format!("demo.a{i}"),
);
}
for i in 0..5 {
create_function(
&kit,
&format!("f_b{i}"),
"demo",
&format!("b{i}"),
&format!("demo.b{i}"),
);
}
create_function(&kit, "f_hub", "demo", "hub", "demo.hub");
let mut edge_idx = 0;
for i in 0..5 {
for j in (i + 1)..5 {
create_calls_edge(
&kit,
&format!("e_a{edge_idx}"),
&format!("f_a{i}"),
&format!("f_a{j}"),
"demo",
);
edge_idx += 1;
}
}
edge_idx = 0;
for i in 0..5 {
for j in (i + 1)..5 {
create_calls_edge(
&kit,
&format!("e_b{edge_idx}"),
&format!("f_b{i}"),
&format!("f_b{j}"),
"demo",
);
edge_idx += 1;
}
}
create_calls_edge(&kit, "e_hub_a", "f_hub", "f_a0", "demo");
create_calls_edge(&kit, "e_hub_b", "f_hub", "f_b0", "demo");
let s = storage(&kit);
let detector = CommunityDetector::new(&*s, "demo");
let communities = detector.detect_communities().expect("detect");
assert!(
!communities.is_empty(),
"Leiden should produce at least one community"
);
let graph = detector.load_calls_graph().expect("load graph");
use std::collections::HashMap;
let mut fqn_to_idx: HashMap<String, NodeIndex> = HashMap::new();
for idx in graph.node_indices() {
if let Some(name) = graph.node_weight(idx) {
fqn_to_idx.insert(name.clone(), idx);
}
}
for c in &communities {
let members: Vec<NodeIndex> = c
.members
.iter()
.filter_map(|fqn| fqn_to_idx.get(fqn).copied())
.collect();
assert!(
!members.is_empty(),
"community {} has no resolvable members: {:?}",
c.id,
c.members
);
assert!(
is_connected_subgraph(&graph, &members),
"Leiden produced disconnected community {} with members {:?} — \
refinement phase should guarantee connectivity (C3, M-7)",
c.id,
c.members
);
}
}
}