use std::collections::HashMap;
use leiden_rs::{GraphDataBuilder, Leiden, LeidenConfig};
use crate::analyses::coupling::run_coupling;
use crate::facts::FactsDb;
use crate::{CodeLoreError, Options, Result};
const LEIDEN_SEED: u64 = 0xC0DE_10E5_AED1_DEED;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommunityRow {
pub path: String,
pub community_id: u32,
pub community_size: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommunitiesResult {
pub rows: Vec<CommunityRow>,
pub modularity: f64,
pub community_count: u32,
}
impl CommunitiesResult {
#[must_use]
pub fn len(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
#[tracing::instrument(name = "communities", skip_all, fields(min_revs = opts.min_revs))]
pub fn run_communities(db: &FactsDb, opts: &Options) -> Result<CommunitiesResult> {
let pairs = run_coupling(db, opts)?;
if pairs.is_empty() {
return Ok(CommunitiesResult {
rows: Vec::new(),
modularity: 0.0,
community_count: 0,
});
}
let mut path_to_id: HashMap<String, usize> = HashMap::new();
let mut id_to_path: Vec<String> = Vec::new();
for pair in &pairs {
for path in [&pair.entity_a, &pair.entity_b] {
if !path_to_id.contains_key(path) {
path_to_id.insert(path.clone(), id_to_path.len());
id_to_path.push(path.clone());
}
}
}
let n = id_to_path.len();
let mut builder = GraphDataBuilder::new(n);
for pair in &pairs {
let u = path_to_id[&pair.entity_a];
let v = path_to_id[&pair.entity_b];
builder.add_edge(u, v, pair.degree).map_err(|e| {
CodeLoreError::Analysis(format!("leiden-rs add_edge({u}, {v}, …): {e}"))
})?;
}
let graph = builder
.build()
.map_err(|e| CodeLoreError::Analysis(format!("leiden-rs build: {e}")))?;
let leiden = Leiden::new(LeidenConfig {
seed: Some(LEIDEN_SEED),
..LeidenConfig::default()
});
let result = leiden
.run(&graph)
.map_err(|e| CodeLoreError::Analysis(format!("leiden-rs run: {e}")))?;
let raw_assignments: Vec<usize> = (0..n).map(|i| result.partition.community_of(i)).collect();
let mut dense_id: HashMap<usize, u32> = HashMap::new();
let mut dense_assignments: Vec<u32> = Vec::with_capacity(n);
for &raw in &raw_assignments {
let next_id = u32::try_from(dense_id.len()).unwrap_or(u32::MAX);
let id = *dense_id.entry(raw).or_insert(next_id);
dense_assignments.push(id);
}
let mut sizes: HashMap<u32, u32> = HashMap::new();
for &cid in &dense_assignments {
*sizes.entry(cid).or_insert(0) += 1;
}
let mut rows: Vec<CommunityRow> = (0..n)
.map(|i| CommunityRow {
path: id_to_path[i].clone(),
community_id: dense_assignments[i],
community_size: *sizes.get(&dense_assignments[i]).unwrap_or(&0),
})
.collect();
rows.sort_by(|a, b| {
a.community_id
.cmp(&b.community_id)
.then(a.path.cmp(&b.path))
});
Ok(CommunitiesResult {
rows,
modularity: result.quality,
community_count: u32::try_from(dense_id.len()).unwrap_or(u32::MAX),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyses::coupling::CouplingRow;
fn pair(a: &str, b: &str, degree: f64) -> CouplingRow {
CouplingRow {
entity_a: a.into(),
entity_b: b.into(),
shared: 1,
revs_a: 10,
revs_b: 10,
average_revs: 10,
degree,
fisher_p: 0.01,
}
}
#[test]
fn two_cliques_yield_two_communities() {
let pairs = vec![
pair("a1", "a2", 100.0),
pair("a1", "a3", 100.0),
pair("a2", "a3", 100.0),
pair("b1", "b2", 100.0),
pair("b1", "b3", 100.0),
pair("b2", "b3", 100.0),
pair("a1", "b1", 1.0), ];
let mut path_to_id: HashMap<String, usize> = HashMap::new();
let mut id_to_path: Vec<String> = Vec::new();
for p in &pairs {
for x in [&p.entity_a, &p.entity_b] {
if !path_to_id.contains_key(x) {
path_to_id.insert(x.clone(), id_to_path.len());
id_to_path.push(x.clone());
}
}
}
let mut builder = GraphDataBuilder::new(id_to_path.len());
for p in &pairs {
builder
.add_edge(path_to_id[&p.entity_a], path_to_id[&p.entity_b], p.degree)
.unwrap();
}
let graph = builder.build().unwrap();
let result = Leiden::new(LeidenConfig::default()).run(&graph).unwrap();
assert!(
result.quality > 0.3,
"expected Q > 0.3 on two-clique fixture, got {}",
result.quality
);
let ca = result.partition.community_of(path_to_id["a1"]);
assert_eq!(ca, result.partition.community_of(path_to_id["a2"]));
assert_eq!(ca, result.partition.community_of(path_to_id["a3"]));
let cb = result.partition.community_of(path_to_id["b1"]);
assert_eq!(cb, result.partition.community_of(path_to_id["b2"]));
assert_eq!(cb, result.partition.community_of(path_to_id["b3"]));
assert_ne!(ca, cb, "two cliques should produce two communities");
}
#[test]
fn leiden_partition_is_deterministic_across_runs() {
let pairs = vec![
pair("a1", "a2", 100.0),
pair("a1", "a3", 100.0),
pair("a2", "a3", 100.0),
pair("b1", "b2", 100.0),
pair("b1", "b3", 100.0),
pair("b2", "b3", 100.0),
pair("a1", "b1", 1.0),
];
let mut path_to_id: HashMap<String, usize> = HashMap::new();
let mut id_to_path: Vec<String> = Vec::new();
for p in &pairs {
for x in [&p.entity_a, &p.entity_b] {
if !path_to_id.contains_key(x) {
path_to_id.insert(x.clone(), id_to_path.len());
id_to_path.push(x.clone());
}
}
}
let build = || {
let mut b = GraphDataBuilder::new(id_to_path.len());
for p in &pairs {
b.add_edge(path_to_id[&p.entity_a], path_to_id[&p.entity_b], p.degree)
.unwrap();
}
b.build().unwrap()
};
let seeded = || LeidenConfig {
seed: Some(LEIDEN_SEED),
..LeidenConfig::default()
};
let r1 = Leiden::new(seeded()).run(&build()).unwrap();
let r2 = Leiden::new(seeded()).run(&build()).unwrap();
let parts1: Vec<_> = (0..id_to_path.len())
.map(|i| r1.partition.community_of(i))
.collect();
let parts2: Vec<_> = (0..id_to_path.len())
.map(|i| r2.partition.community_of(i))
.collect();
assert_eq!(
parts1, parts2,
"seeded Leiden must produce identical partitions across runs"
);
}
}