use crate::error::Result;
use crate::graph::GraphView;
use crate::types::NodeId;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct CommunityConfig {
pub resolution: f64,
pub max_iterations: usize,
pub min_gain: f64,
}
impl Default for CommunityConfig {
fn default() -> Self {
CommunityConfig {
resolution: 1.0,
max_iterations: 100,
min_gain: 0.0001,
}
}
}
pub struct LouvainCommunity {
config: CommunityConfig,
}
impl LouvainCommunity {
pub fn new(config: CommunityConfig) -> Self {
LouvainCommunity { config }
}
pub fn with_defaults() -> Self {
LouvainCommunity {
config: CommunityConfig::default(),
}
}
pub async fn detect<G: GraphView>(&self, graph: &G) -> Result<HashMap<NodeId, usize>> {
let nodes = graph.get_all_nodes().await?;
if nodes.is_empty() {
return Ok(HashMap::new());
}
let edges = graph.get_all_edges().await?;
let config = self.config.clone();
tokio::task::spawn_blocking(move || Self::detect_cpu(nodes, edges, config))
.await
.map_err(|e| crate::error::NopalError::custom(format!("community detect join error: {e}")))?
}
fn detect_cpu(
nodes: Vec<crate::types::Node>,
edges: Vec<crate::types::Edge>,
config: CommunityConfig,
) -> Result<HashMap<NodeId, usize>> {
let louvain = LouvainCommunity { config };
let mut sorted_nodes = nodes;
sorted_nodes.sort_unstable_by_key(|n| n.id);
let mut communities: HashMap<NodeId, usize> = sorted_nodes
.iter()
.enumerate()
.map(|(i, node)| (node.id, i))
.collect();
let mut adjacency: HashMap<NodeId, HashMap<NodeId, f64>> = HashMap::new();
for edge in &edges {
adjacency.entry(edge.source).or_default().insert(edge.target, 1.0);
adjacency.entry(edge.target).or_default().insert(edge.source, 1.0);
}
let total_weight: f64 = adjacency.values()
.flat_map(|nbrs| nbrs.values())
.sum::<f64>()
/ 2.0;
let mut degrees: HashMap<NodeId, f64> = HashMap::new();
for (node, neighbors) in &adjacency {
let degree: f64 = neighbors.values().sum();
degrees.insert(*node, degree);
}
let mut improved = true;
let mut iteration = 0;
while improved && iteration < louvain.config.max_iterations {
improved = false;
iteration += 1;
for node in &sorted_nodes {
let node_id = node.id;
let current_community = communities[&node_id];
let mut best_community = current_community;
let mut best_gain = 0.0;
let neighbor_communities =
louvain.get_neighbor_communities(node_id, &adjacency, &communities);
for &neighbor_community in &neighbor_communities {
if neighbor_community == current_community {
continue;
}
let gain = louvain.modularity_gain(
node_id,
neighbor_community,
&communities,
&adjacency,
°rees,
total_weight,
);
if gain > best_gain {
best_gain = gain;
best_community = neighbor_community;
}
}
if best_gain > louvain.config.min_gain && best_community != current_community {
communities.insert(node_id, best_community);
improved = true;
}
}
}
louvain.renumber_communities(communities)
}
fn get_neighbor_communities(
&self,
node: NodeId,
adjacency: &HashMap<NodeId, HashMap<NodeId, f64>>,
communities: &HashMap<NodeId, usize>,
) -> Vec<usize> {
let mut seen = HashSet::new();
if let Some(neighbors) = adjacency.get(&node) {
for neighbor in neighbors.keys() {
if let Some(&community) = communities.get(neighbor) {
seen.insert(community);
}
}
}
if let Some(¤t) = communities.get(&node) {
seen.insert(current);
}
let mut result: Vec<usize> = seen.into_iter().collect();
result.sort_unstable();
result
}
fn modularity_gain(
&self,
node: NodeId,
target_community: usize,
communities: &HashMap<NodeId, usize>,
adjacency: &HashMap<NodeId, HashMap<NodeId, f64>>,
degrees: &HashMap<NodeId, f64>,
total_weight: f64,
) -> f64 {
let node_degree = degrees.get(&node).copied().unwrap_or(0.0);
let current_community = *communities.get(&node).unwrap_or(&usize::MAX);
let mut k_i_in_t = 0.0_f64;
let mut k_i_in_s = 0.0_f64;
if let Some(neighbors) = adjacency.get(&node) {
for (nbr, &w) in neighbors {
match communities.get(nbr).copied() {
Some(c) if c == target_community => k_i_in_t += w,
Some(c) if c == current_community => k_i_in_s += w,
_ => {}
}
}
}
let mut sigma_t = 0.0_f64;
let mut sigma_s = 0.0_f64;
for (&other, &comm) in communities {
if let Some(°) = degrees.get(&other) {
if comm == target_community { sigma_t += deg; }
if comm == current_community { sigma_s += deg; }
}
}
let m2 = 2.0 * total_weight;
(k_i_in_t - k_i_in_s) / m2
- self.config.resolution * (sigma_t - sigma_s + node_degree) * node_degree / (m2 * m2)
}
fn renumber_communities(
&self,
communities: HashMap<NodeId, usize>,
) -> Result<HashMap<NodeId, usize>> {
let mut unique_communities: Vec<usize> = communities
.values()
.copied()
.collect::<HashSet<_>>()
.into_iter()
.collect();
unique_communities.sort_unstable();
let community_map: HashMap<usize, usize> = unique_communities
.iter()
.enumerate()
.map(|(new_id, &old_id)| (old_id, new_id))
.collect();
Ok(communities
.into_iter()
.map(|(node, old_community)| {
let new_community = community_map[&old_community];
(node, new_community)
})
.collect())
}
pub fn count_communities(communities: &HashMap<NodeId, usize>) -> usize {
communities.values().copied().collect::<HashSet<_>>().len()
}
pub async fn modularity<G: GraphView>(
&self,
graph: &G,
communities: &HashMap<NodeId, usize>,
) -> Result<f64> {
let edges = graph.get_all_edges().await?;
let nodes = graph.get_all_nodes().await?;
let mut total_weight = 0.0;
let mut community_internal: HashMap<usize, f64> = HashMap::new();
let mut community_degrees: HashMap<usize, f64> = HashMap::new();
let mut adjacency: HashMap<NodeId, HashMap<NodeId, f64>> = HashMap::new();
for edge in &edges {
let weight = 1.0;
adjacency
.entry(edge.source)
.or_default()
.insert(edge.target, weight);
adjacency
.entry(edge.target)
.or_default()
.insert(edge.source, weight);
total_weight += weight;
}
for node in &nodes {
let node_id = node.id;
let community = communities.get(&node_id).copied().unwrap_or(0);
if let Some(neighbors) = adjacency.get(&node_id) {
let degree: f64 = neighbors.values().sum();
*community_degrees.entry(community).or_insert(0.0) += degree;
for (neighbor, &weight) in neighbors {
let neighbor_community = communities.get(neighbor).copied().unwrap_or(0);
if community == neighbor_community {
*community_internal.entry(community).or_insert(0.0) += weight;
}
}
}
}
let m = total_weight;
let mut q = 0.0;
for (&community, &internal) in &community_internal {
let degree_sum = community_degrees.get(&community).copied().unwrap_or(0.0);
q += (internal / (2.0 * m)) - (degree_sum / (2.0 * m)).powi(2);
}
Ok(q)
}
}
#[derive(Debug, Clone)]
pub struct LeidenConfig {
pub gamma: f64,
pub max_iterations: usize,
pub min_gain: f64,
}
impl Default for LeidenConfig {
fn default() -> Self {
LeidenConfig { gamma: 0.1, max_iterations: 10, min_gain: 1e-9 }
}
}
pub struct LeidenCommunity {
config: LeidenConfig,
}
impl LeidenCommunity {
pub fn new(config: LeidenConfig) -> Self {
LeidenCommunity { config }
}
pub fn with_defaults() -> Self {
LeidenCommunity { config: LeidenConfig::default() }
}
pub fn with_gamma(gamma: f64) -> Self {
LeidenCommunity { config: LeidenConfig { gamma, ..LeidenConfig::default() } }
}
pub async fn detect<G: GraphView>(&self, graph: &G) -> Result<HashMap<NodeId, usize>> {
let nodes = graph.get_all_nodes().await?;
if nodes.is_empty() {
return Ok(HashMap::new());
}
let edges = graph.get_all_edges().await?;
let config = self.config.clone();
tokio::task::spawn_blocking(move || Self::detect_cpu(nodes, edges, config))
.await
.map_err(|e| crate::error::NopalError::custom(
format!("leiden detect join error: {e}")
))?
}
pub fn count_communities(communities: &HashMap<NodeId, usize>) -> usize {
communities.values().copied().collect::<HashSet<_>>().len()
}
fn detect_cpu(
nodes: Vec<crate::types::Node>,
edges: Vec<crate::types::Edge>,
config: LeidenConfig,
) -> Result<HashMap<NodeId, usize>> {
if nodes.is_empty() {
return Ok(HashMap::new());
}
let mut adjacency: HashMap<NodeId, HashMap<NodeId, f64>> = HashMap::new();
for edge in &edges {
adjacency.entry(edge.source).or_default().insert(edge.target, 1.0);
adjacency.entry(edge.target).or_default().insert(edge.source, 1.0);
}
let mut node_ids: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
node_ids.sort_unstable();
let mut communities: HashMap<NodeId, usize> = node_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let mut sizes: HashMap<usize, usize> = communities
.iter()
.map(|(_, &c)| (c, 1))
.collect();
let mut e_in: HashMap<usize, f64> = (0..node_ids.len()).map(|i| (i, 0.0)).collect();
for _iter in 0..config.max_iterations {
let phase1_improved = Self::phase1_local_move(
&node_ids,
&adjacency,
&mut communities,
&mut sizes,
&mut e_in,
config.gamma,
config.min_gain,
);
let phase2_changed = Self::phase2_refine(
&node_ids,
&adjacency,
&mut communities,
&mut sizes,
&mut e_in,
config.gamma,
);
if !phase1_improved && !phase2_changed {
break;
}
}
Self::renumber_communities(communities)
}
fn phase1_local_move(
node_ids: &[NodeId],
adjacency: &HashMap<NodeId, HashMap<NodeId, f64>>,
communities: &mut HashMap<NodeId, usize>,
sizes: &mut HashMap<usize, usize>,
e_in: &mut HashMap<usize, f64>,
gamma: f64,
min_gain: f64,
) -> bool {
let mut any_improved = false;
loop {
let mut pass_improved = false;
for &node_id in node_ids {
let current_comm = communities[&node_id];
let n_s = sizes[¤t_comm] as f64;
let e_to_self_comm = Self::edge_weight_to_community(
node_id, current_comm, adjacency, communities,
);
let mut best_gain = min_gain;
let mut best_comm = current_comm;
let mut candidate_comms: Vec<usize> = adjacency
.get(&node_id)
.map(|nbrs| {
let mut cs: Vec<usize> = nbrs.keys()
.filter_map(|nbr| {
let c = communities[nbr];
if c != current_comm { Some(c) } else { None }
})
.collect();
cs.sort_unstable();
cs.dedup();
cs
})
.unwrap_or_default();
candidate_comms.sort_unstable();
candidate_comms.dedup();
for target_comm in candidate_comms {
let n_t = sizes[&target_comm] as f64;
let e_to_target = Self::edge_weight_to_community(
node_id, target_comm, adjacency, communities,
);
let gain = e_to_target - e_to_self_comm + gamma * (n_s - 1.0 - n_t);
if gain > best_gain {
best_gain = gain;
best_comm = target_comm;
}
}
if best_comm != current_comm {
let e_contrib = Self::edge_weight_to_community(
node_id, current_comm, adjacency, communities,
);
*e_in.entry(current_comm).or_insert(0.0) -= e_contrib;
*sizes.entry(current_comm).or_insert(1) -= 1;
*communities.get_mut(&node_id).expect("node must be in communities") = best_comm;
let e_to_new = Self::edge_weight_to_community(
node_id, best_comm, adjacency, communities,
);
*e_in.entry(best_comm).or_insert(0.0) += e_to_new;
*sizes.entry(best_comm).or_insert(0) += 1;
pass_improved = true;
any_improved = true;
}
}
if !pass_improved {
break;
}
}
any_improved
}
fn phase2_refine(
node_ids: &[NodeId],
adjacency: &HashMap<NodeId, HashMap<NodeId, f64>>,
communities: &mut HashMap<NodeId, usize>,
sizes: &mut HashMap<usize, usize>,
e_in: &mut HashMap<usize, f64>,
gamma: f64,
) -> bool {
let mut by_comm: HashMap<usize, Vec<NodeId>> = HashMap::new();
for &nid in node_ids {
by_comm.entry(communities[&nid]).or_default().push(nid);
}
for nodes_in_c in by_comm.values_mut() {
nodes_in_c.sort_unstable();
}
let mut any_changed = false;
let mut next_ref_id: usize = node_ids.len();
let mut sorted_comms: Vec<(usize, &Vec<NodeId>)> = by_comm.iter()
.map(|(&id, nodes)| (id, nodes))
.collect();
sorted_comms.sort_unstable_by_key(|(id, _)| *id);
for (base_comm_id, c_nodes) in sorted_comms {
let c_size = c_nodes.len();
if c_size <= 1 {
continue;
}
let c_set: HashSet<NodeId> = c_nodes.iter().copied().collect();
let e_c: HashMap<NodeId, f64> = c_nodes.iter().map(|&v| {
let w = adjacency.get(&v)
.map(|nbrs| nbrs.iter()
.filter(|(u, _)| c_set.contains(*u) && **u != v)
.map(|(_, &w)| w)
.sum::<f64>())
.unwrap_or(0.0);
(v, w)
}).collect();
let mut ref_comm: HashMap<NodeId, usize> = c_nodes.iter()
.enumerate()
.map(|(i, &v)| (v, next_ref_id + i))
.collect();
let ref_id_base = next_ref_id;
next_ref_id += c_size;
let mut ref_sizes: HashMap<usize, usize> = (ref_id_base..ref_id_base + c_size)
.map(|id| (id, 1))
.collect();
let mut ref_e_int: HashMap<usize, f64> = (ref_id_base..ref_id_base + c_size)
.map(|id| (id, 0.0))
.collect();
for &v in c_nodes {
let v_ref_comm_initial = ref_comm[&v];
if ref_sizes[&v_ref_comm_initial] != 1 {
continue;
}
let e_cv = e_c[&v];
if e_cv < gamma * (c_size as f64 - 1.0) {
continue;
}
let v_ref_comm = v_ref_comm_initial;
let mut candidate_refs: Vec<usize> = adjacency.get(&v)
.map(|nbrs| {
let mut cs: Vec<usize> = nbrs.keys()
.filter(|u| c_set.contains(*u))
.filter_map(|u| {
let rc = ref_comm[u];
if rc != v_ref_comm { Some(rc) } else { None }
})
.collect();
cs.sort_unstable();
cs.dedup();
cs
})
.unwrap_or_default();
candidate_refs.sort_unstable();
candidate_refs.dedup();
let mut best_gain = 0.0_f64;
let mut best_ref: Option<usize> = None;
for r in candidate_refs {
let r_size = ref_sizes[&r] as f64;
let r_e_int = ref_e_int[&r];
let sum_ec_r: f64 = c_nodes.iter()
.filter(|&&u| ref_comm[&u] == r)
.map(|&u| e_c[&u])
.sum();
let e_ext_r = sum_ec_r - 2.0 * r_e_int;
let threshold = gamma * r_size * (c_size as f64 - r_size);
if e_ext_r < threshold {
continue; }
let e_v_r: f64 = adjacency.get(&v)
.map(|nbrs| nbrs.iter()
.filter(|(u, _)| ref_comm.get(*u) == Some(&r))
.map(|(_, &w)| w)
.sum())
.unwrap_or(0.0);
let gain = e_v_r - gamma * r_size;
if gain > best_gain {
best_gain = gain;
best_ref = Some(r);
}
}
if let Some(target_r) = best_ref {
let e_v_r: f64 = adjacency.get(&v)
.map(|nbrs| nbrs.iter()
.filter(|(u, _)| ref_comm.get(*u) == Some(&target_r))
.map(|(_, &w)| w)
.sum())
.unwrap_or(0.0);
*ref_comm.get_mut(&v).expect("v in ref_comm") = target_r;
*ref_sizes.entry(target_r).or_insert(0) += 1;
*ref_e_int.entry(target_r).or_insert(0.0) += e_v_r;
*ref_sizes.entry(v_ref_comm).or_insert(1) -= 1;
}
}
let refined_comm_ids: HashSet<usize> = c_nodes.iter()
.map(|&v| ref_comm[&v])
.collect();
if refined_comm_ids.len() == 1 {
for &v in c_nodes {
*communities.get_mut(&v).expect("v in communities") = base_comm_id;
}
continue;
}
if refined_comm_ids.len() == c_size {
for &v in c_nodes {
let new_c = ref_id_base + c_nodes.iter().position(|&x| x == v).unwrap_or(0);
*communities.get_mut(&v).expect("v in communities") = new_c;
sizes.insert(new_c, 1);
e_in.insert(new_c, 0.0);
any_changed = true;
}
*sizes.entry(base_comm_id).or_insert(c_size) -= c_size;
e_in.entry(base_comm_id).and_modify(|e| *e = 0.0);
continue;
}
any_changed = true;
let mut sorted_ref_ids: Vec<usize> = refined_comm_ids.iter().copied().collect();
sorted_ref_ids.sort_unstable();
let mut ref_to_global: HashMap<usize, usize> = HashMap::new();
for (idx, ref_id) in sorted_ref_ids.iter().enumerate() {
if idx == 0 {
ref_to_global.insert(*ref_id, base_comm_id);
} else {
let new_id = next_ref_id;
next_ref_id += 1;
ref_to_global.insert(*ref_id, new_id);
}
}
sizes.insert(base_comm_id, 0);
e_in.insert(base_comm_id, 0.0);
for &v in c_nodes {
let old_ref = ref_comm[&v];
let global_id = ref_to_global[&old_ref];
*communities.get_mut(&v).expect("v in communities") = global_id;
*sizes.entry(global_id).or_insert(0) += 1;
let e_v_new_comm: f64 = adjacency.get(&v)
.map(|nbrs| nbrs.iter()
.filter(|(u, _)| ref_comm.get(*u) == Some(&old_ref) && **u != v)
.map(|(_, &w)| w)
.sum())
.unwrap_or(0.0);
*e_in.entry(global_id).or_insert(0.0) += e_v_new_comm;
}
}
any_changed
}
fn edge_weight_to_community(
node: NodeId,
community: usize,
adjacency: &HashMap<NodeId, HashMap<NodeId, f64>>,
communities: &HashMap<NodeId, usize>,
) -> f64 {
adjacency.get(&node)
.map(|nbrs| nbrs.iter()
.filter(|(nbr, _)| communities.get(*nbr) == Some(&community) && **nbr != node)
.map(|(_, &w)| w)
.sum())
.unwrap_or(0.0)
}
fn renumber_communities(
communities: HashMap<NodeId, usize>,
) -> Result<HashMap<NodeId, usize>> {
let mut unique: Vec<usize> = communities.values().copied().collect::<HashSet<_>>()
.into_iter().collect();
unique.sort_unstable();
let remap: HashMap<usize, usize> = unique.into_iter().enumerate()
.map(|(new_id, old_id)| (old_id, new_id))
.collect();
Ok(communities.into_iter()
.map(|(node, old)| (node, remap[&old]))
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Graph;
use crate::types::{Node, Edge};
use std::collections::HashMap;
fn make_node() -> Node {
Node {
id: uuid::Uuid::new_v4(),
label: "N".to_string(),
properties: HashMap::new(),
kind: Default::default(),
}
}
fn make_edge(src: NodeId, tgt: NodeId) -> Edge {
Edge {
id: uuid::Uuid::new_v4(),
source: src,
target: tgt,
edge_type: "E".to_string(),
properties: HashMap::new(),
}
}
#[tokio::test]
async fn test_louvain_two_triangles_separates() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let a = tx.add_node(make_node()).await.unwrap();
let b = tx.add_node(make_node()).await.unwrap();
let c = tx.add_node(make_node()).await.unwrap();
let d = tx.add_node(make_node()).await.unwrap();
let e = tx.add_node(make_node()).await.unwrap();
let f = tx.add_node(make_node()).await.unwrap();
for (s, t) in [(a,b),(b,a),(b,c),(c,b),(c,a),(a,c)] {
tx.add_edge(make_edge(s, t)).unwrap();
}
for (s, t) in [(d,e),(e,d),(e,f),(f,e),(f,d),(d,f)] {
tx.add_edge(make_edge(s, t)).unwrap();
}
tx.add_edge(make_edge(c, d)).unwrap();
tx.add_edge(make_edge(d, c)).unwrap();
tx.commit().await.unwrap();
let louvain = LouvainCommunity::with_defaults();
let communities = louvain.detect(&graph).await.unwrap();
let n = LouvainCommunity::count_communities(&communities);
assert_eq!(n, 2, "Dos triángulos + puente → 2 comunidades, obtuvo {}", n);
assert_eq!(communities[&a], communities[&b], "a y b deben estar juntos");
assert_eq!(communities[&b], communities[&c], "b y c deben estar juntos");
assert_eq!(communities[&d], communities[&e], "d y e deben estar juntos");
assert_eq!(communities[&e], communities[&f], "e y f deben estar juntos");
assert_ne!(communities[&a], communities[&d], "Triángulos deben estar separados");
}
#[tokio::test]
async fn test_community_simple() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let a = tx.add_node(make_node()).await.unwrap();
let b = tx.add_node(make_node()).await.unwrap();
let c = tx.add_node(make_node()).await.unwrap();
let d = tx.add_node(make_node()).await.unwrap();
let e = tx.add_node(make_node()).await.unwrap();
let f = tx.add_node(make_node()).await.unwrap();
for (s, t) in [(a,b),(b,c),(c,a)] {
tx.add_edge(make_edge(s, t)).unwrap();
}
for (s, t) in [(d,e),(e,f),(f,d)] {
tx.add_edge(make_edge(s, t)).unwrap();
}
tx.add_edge(make_edge(c, d)).unwrap();
tx.commit().await.unwrap();
let louvain = LouvainCommunity::with_defaults();
let communities = louvain.detect(&graph).await.unwrap();
let num_communities = LouvainCommunity::count_communities(&communities);
assert!(num_communities >= 1 && num_communities <= 6,
"Expected 1-6 communities, got {}", num_communities);
for &id in &[a, b, c, d, e, f] {
assert!(communities.contains_key(&id), "Node missing from communities");
}
}
async fn add_test_node(
tx: &mut crate::transaction::Transaction,
label: &str,
) -> NodeId {
tx.add_node(Node {
id: uuid::Uuid::new_v4(),
label: label.to_string(),
properties: HashMap::new(),
kind: Default::default(),
}).await.unwrap()
}
fn add_test_edge(
tx: &mut crate::transaction::Transaction,
src: NodeId,
tgt: NodeId,
) {
tx.add_edge(Edge {
id: uuid::Uuid::new_v4(),
source: src,
target: tgt,
edge_type: "E".to_string(),
properties: HashMap::new(),
}).unwrap();
}
#[tokio::test]
async fn test_leiden_empty_graph() {
let graph = Graph::in_memory().await.unwrap();
let leiden = LeidenCommunity::with_defaults();
let communities = leiden.detect(&graph).await.unwrap();
assert!(communities.is_empty());
}
#[tokio::test]
async fn test_leiden_single_node() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let id = add_test_node(&mut tx, "N").await;
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_defaults();
let communities = leiden.detect(&graph).await.unwrap();
assert_eq!(communities.len(), 1);
assert_eq!(communities[&id], 0);
}
#[tokio::test]
async fn test_leiden_two_triangles_with_bridge() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let a = add_test_node(&mut tx, "A").await;
let b = add_test_node(&mut tx, "B").await;
let c = add_test_node(&mut tx, "C").await;
let d = add_test_node(&mut tx, "D").await;
let e = add_test_node(&mut tx, "E").await;
let f = add_test_node(&mut tx, "F").await;
add_test_edge(&mut tx, a, b);
add_test_edge(&mut tx, b, c);
add_test_edge(&mut tx, c, a);
add_test_edge(&mut tx, d, e);
add_test_edge(&mut tx, e, f);
add_test_edge(&mut tx, f, d);
add_test_edge(&mut tx, c, d);
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_gamma(0.1);
let communities = leiden.detect(&graph).await.unwrap();
let n_comm = LeidenCommunity::count_communities(&communities);
assert!(n_comm >= 1 && n_comm <= 4,
"Esperaba 1-4 comunidades con gamma=0.1, obtuvo {}", n_comm);
for &node in &[a, b, c, d, e, f] {
assert!(communities.contains_key(&node), "Nodo sin comunidad asignada");
}
}
#[tokio::test]
async fn test_leiden_complete_graph_k4_low_gamma() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let ids: Vec<NodeId> = {
let mut v = Vec::new();
for _ in 0..4 {
v.push(add_test_node(&mut tx, "N").await);
}
v
};
for i in 0..4 {
for j in (i + 1)..4 {
add_test_edge(&mut tx, ids[i], ids[j]);
}
}
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_gamma(0.0);
let communities = leiden.detect(&graph).await.unwrap();
let n_comm = LeidenCommunity::count_communities(&communities);
assert_eq!(n_comm, 1, "K4 con gamma=0.0 debe producir 1 comunidad, obtuvo {}", n_comm);
}
#[tokio::test]
async fn test_leiden_complete_graph_k4_high_gamma() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let ids: Vec<NodeId> = {
let mut v = Vec::new();
for _ in 0..4 {
v.push(add_test_node(&mut tx, "N").await);
}
v
};
for i in 0..4 {
for j in (i + 1)..4 {
add_test_edge(&mut tx, ids[i], ids[j]);
}
}
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_gamma(2.0);
let communities = leiden.detect(&graph).await.unwrap();
assert_eq!(communities.len(), 4, "Todos los nodos deben tener asignación");
}
#[tokio::test]
async fn test_leiden_deterministic() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let ids: Vec<NodeId> = {
let mut v = Vec::new();
for _ in 0..6 {
v.push(add_test_node(&mut tx, "N").await);
}
v
};
let pairs: &[(usize, usize)] = &[(0,1),(1,2),(2,0),(3,4),(4,5),(5,3),(2,3)];
for &(i, j) in pairs {
add_test_edge(&mut tx, ids[i], ids[j]);
}
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_gamma(0.1);
let r1 = leiden.detect(&graph).await.unwrap();
let r2 = leiden.detect(&graph).await.unwrap();
for &id in &ids {
assert_eq!(r1[&id], r2[&id],
"Leiden no es determinista: resultado distinto para el mismo nodo en dos runs");
}
}
#[tokio::test]
async fn test_leiden_linear_chain() {
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let ids: Vec<NodeId> = {
let mut v = Vec::new();
for _ in 0..5 {
v.push(add_test_node(&mut tx, "N").await);
}
v
};
for i in 0..4 {
add_test_edge(&mut tx, ids[i], ids[i + 1]);
}
tx.commit().await.unwrap();
let leiden = LeidenCommunity::with_defaults();
let communities = leiden.detect(&graph).await.unwrap();
assert_eq!(communities.len(), 5, "Todos los nodos deben tener asignación");
let n_comm = LeidenCommunity::count_communities(&communities);
assert!(n_comm >= 1, "Al menos 1 comunidad debe existir");
}
#[tokio::test]
async fn test_florentine_families_community_counts() {
use std::collections::BTreeMap;
let graph = Graph::in_memory().await.unwrap();
let mut tx = graph.begin_transaction().await.unwrap();
let names = [
"Acciaiuoli", "Albizzi", "Barbadori", "Bischeri", "Castellani",
"Ginori", "Guadagni", "Lamberteschi", "Medici", "Pazzi",
"Peruzzi", "Ridolfi", "Salviati", "Strozzi", "Tornabuoni",
];
let mut ids: BTreeMap<&str, NodeId> = BTreeMap::new();
for (i, name) in names.iter().enumerate() {
let node = Node {
id: uuid::Uuid::from_u128((i as u128 + 1) << 96),
label: "Family".to_string(),
properties: std::collections::HashMap::from([
("name".to_string(), crate::types::PropertyValue::String(name.to_string())),
]),
kind: Default::default(),
};
ids.insert(name, tx.add_node(node).await.unwrap());
}
let edges_undirected = [
("Acciaiuoli", "Medici"),
("Castellani", "Peruzzi"),
("Castellani", "Strozzi"),
("Castellani", "Barbadori"),
("Medici", "Barbadori"),
("Medici", "Ridolfi"),
("Medici", "Tornabuoni"),
("Medici", "Albizzi"),
("Medici", "Salviati"),
("Salviati", "Pazzi"),
("Peruzzi", "Strozzi"),
("Peruzzi", "Bischeri"),
("Strozzi", "Ridolfi"),
("Strozzi", "Bischeri"),
("Ridolfi", "Tornabuoni"),
("Tornabuoni", "Guadagni"),
("Albizzi", "Ginori"),
("Albizzi", "Guadagni"),
("Bischeri", "Guadagni"),
("Guadagni", "Lamberteschi"),
];
for (a, b) in &edges_undirected {
tx.add_edge(make_edge(ids[a], ids[b])).unwrap();
tx.add_edge(make_edge(ids[b], ids[a])).unwrap();
}
tx.commit().await.unwrap();
let louvain = LouvainCommunity::with_defaults();
let louv_comm = louvain.detect(&graph).await.unwrap();
let n_louv = LouvainCommunity::count_communities(&louv_comm);
let leiden = LeidenCommunity::with_defaults();
let leid_comm = leiden.detect(&graph).await.unwrap();
let n_leid = LeidenCommunity::count_communities(&leid_comm);
eprintln!("=== Florentine Families community detection ===");
eprintln!("Louvain: {} comunidades", n_louv);
eprintln!("Leiden: {} comunidades", n_leid);
let mut louv_groups: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
let mut leid_groups: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
for name in &names {
louv_groups.entry(louv_comm[&ids[name]]).or_default().push(name);
leid_groups.entry(leid_comm[&ids[name]]).or_default().push(name);
}
for (c, members) in &louv_groups { eprintln!(" Louvain {}: {:?}", c, members); }
for (c, members) in &leid_groups { eprintln!(" Leiden {}: {:?}", c, members); }
assert_eq!(louv_comm.len(), 15, "Louvain: todos los nodos deben tener comunidad");
assert_eq!(leid_comm.len(), 15, "Leiden: todos los nodos deben tener comunidad");
assert_eq!(n_louv, 5, "Louvain Florentine: esperaba 5 comunidades, obtuvo {}", n_louv);
assert_eq!(n_leid, 5, "Leiden Florentine: esperaba 5 comunidades, obtuvo {}", n_leid);
let medici_comm = louv_comm[&ids["Medici"]];
for &ally in &["Acciaiuoli", "Ridolfi", "Tornabuoni"] {
assert_eq!(louv_comm[&ids[ally]], medici_comm,
"Louvain: {} debe estar con Medici", ally);
}
let strozzi_comm = louv_comm[&ids["Strozzi"]];
for &ally in &["Bischeri", "Castellani", "Peruzzi"] {
assert_eq!(louv_comm[&ids[ally]], strozzi_comm,
"Louvain: {} debe estar con Strozzi", ally);
}
assert_eq!(louv_comm[&ids["Barbadori"]], strozzi_comm,
"Louvain: Barbadori debe estar con Strozzi (broker equidistante — resultado estable con Louvain)");
let medici_leid = leid_comm[&ids["Medici"]];
for &ally in &["Acciaiuoli", "Ridolfi", "Tornabuoni"] {
assert_eq!(leid_comm[&ids[ally]], medici_leid,
"Leiden: {} debe estar con Medici", ally);
}
let strozzi_leid = leid_comm[&ids["Strozzi"]];
for &ally in &["Bischeri", "Castellani", "Peruzzi"] {
assert_eq!(leid_comm[&ids[ally]], strozzi_leid,
"Leiden: {} debe estar con Strozzi", ally);
}
assert_eq!(leid_comm[&ids["Pazzi"]], leid_comm[&ids["Salviati"]],
"Leiden: Pazzi y Salviati deben estar juntos");
}
}