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_LOUVAIN_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 = louvain(&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::HashMap;
let mut weights: HashMap<(String, String), f64> = HashMap::new();
let mut node_set: std::collections::HashSet<String> = std::collections::HashSet::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();
let mut nodes: Vec<String> = node_set.into_iter().collect();
nodes.sort();
for name in &nodes {
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 louvain(graph: &UnGraph<String, f64>, resolution: f64) -> 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_LOUVAIN_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 degrees: Vec<f64> = (0..work.node_count())
.map(|i| {
let ni = NodeIndex::new(i);
work.edges(ni).map(|e| *e.weight()).sum::<f64>()
+ work
.edges_connecting(ni, ni)
.map(|e| *e.weight())
.sum::<f64>()
})
.collect();
let mut improved = true;
while improved {
improved = false;
for v_idx in 0..work.node_count() {
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 mut comm_tot: HashMap<usize, f64> = HashMap::new();
for (idx, &c) in node_to_comm.iter().enumerate() {
*comm_tot.entry(c).or_insert(0.0) += degrees[idx];
}
let cur_comm_tot = comm_tot.get(&v_comm).copied().unwrap_or(0.0);
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.get(&c).copied().unwrap_or(0.0);
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 {
node_to_comm[v_idx] = best_comm;
improved = true;
}
}
}
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) + 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 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");
}
}