use crate::graph::GraphData;
use crate::leiden::{Leiden, LeidenConfig, LeidenOutput, QualityType};
use crate::partition::Partition;
#[cfg(feature = "rayon")]
use rayon::prelude::*;
#[derive(Debug, Clone)]
pub struct ResolutionEntry {
pub resolution: f64,
pub num_communities: usize,
pub quality: f64,
pub partition: Partition,
}
pub fn resolution_scan(
data: &GraphData,
quality: QualityType,
resolution_range: (f64, f64),
num_points: usize,
seed: Option<u64>,
) -> crate::error::Result<Vec<ResolutionEntry>> {
if num_points == 0 {
return Ok(Vec::new());
}
let compute_entry = |i: usize| -> crate::error::Result<ResolutionEntry> {
let gamma = if num_points == 1 {
resolution_range.0
} else {
resolution_range.0
+ (resolution_range.1 - resolution_range.0) * (i as f64) / ((num_points - 1) as f64)
};
let config = LeidenConfig {
resolution: gamma,
quality,
seed: seed.map(|s| s + i as u64),
..Default::default()
};
let LeidenOutput {
partition,
quality: q,
} = Leiden::new(config).run(data)?;
Ok(ResolutionEntry {
resolution: gamma,
num_communities: partition.num_communities(),
quality: q,
partition,
})
};
#[cfg(feature = "rayon")]
let entries: Vec<ResolutionEntry> = (0..num_points)
.into_par_iter()
.map(compute_entry)
.collect::<crate::error::Result<Vec<_>>>()?;
#[cfg(not(feature = "rayon"))]
let entries: Vec<ResolutionEntry> = (0..num_points)
.map(compute_entry)
.collect::<crate::error::Result<Vec<_>>>()?;
Ok(entries)
}
pub fn resolution_profile(
data: &GraphData,
quality: QualityType,
resolution_range: (f64, f64),
seed: Option<u64>,
min_diff_resolution: f64,
min_diff_quality: f64,
) -> crate::error::Result<Vec<ResolutionEntry>> {
let mut entries = Vec::new();
bisect(
data,
quality,
resolution_range.0,
resolution_range.1,
seed.unwrap_or(0),
min_diff_resolution,
&mut entries,
)?;
entries.sort_by(|a, b| {
a.resolution
.partial_cmp(&b.resolution)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut deduped: Vec<ResolutionEntry> = Vec::new();
for entry in entries {
if let Some(last) = deduped.last() {
if last.num_communities == entry.num_communities
&& (last.quality - entry.quality).abs() < min_diff_quality
{
continue;
}
}
deduped.push(entry);
}
Ok(deduped)
}
fn bisect(
data: &GraphData,
quality: QualityType,
gamma_low: f64,
gamma_high: f64,
seed: u64,
min_diff_resolution: f64,
entries: &mut Vec<ResolutionEntry>,
) -> crate::error::Result<()> {
let n = data.node_count();
if n == 0 {
let config = LeidenConfig {
resolution: gamma_low,
quality,
seed: Some(seed),
..Default::default()
};
let LeidenOutput {
partition,
quality: q,
} = Leiden::new(config).run(data)?;
entries.push(ResolutionEntry {
resolution: gamma_low,
num_communities: partition.num_communities(),
quality: q,
partition,
});
return Ok(());
}
let config_low = LeidenConfig {
resolution: gamma_low,
quality,
seed: Some(seed),
..Default::default()
};
let LeidenOutput {
partition: p_low,
quality: q_low,
} = Leiden::new(config_low).run(data)?;
let config_high = LeidenConfig {
resolution: gamma_high,
quality,
seed: Some(seed.wrapping_add(1)),
..Default::default()
};
let LeidenOutput {
partition: p_high,
quality: _q_high,
} = Leiden::new(config_high).run(data)?;
let range = gamma_high - gamma_low;
if !partitions_equal(&p_low, &p_high, n) && range > min_diff_resolution {
let mid = if gamma_low > 0.0 && gamma_high > 0.0 {
(gamma_low * gamma_high).sqrt()
} else {
(gamma_low + gamma_high) / 2.0
};
bisect(
data,
quality,
gamma_low,
mid,
seed.wrapping_add(2),
min_diff_resolution,
entries,
)?;
bisect(
data,
quality,
mid,
gamma_high,
seed.wrapping_add(3),
min_diff_resolution,
entries,
)?;
} else {
entries.push(ResolutionEntry {
resolution: gamma_low,
num_communities: p_low.num_communities(),
quality: q_low,
partition: p_low,
});
}
Ok(())
}
fn partitions_equal(p1: &Partition, p2: &Partition, n: usize) -> bool {
if p1.num_communities() != p2.num_communities() {
return false;
}
let norm1 = normalize_partition(p1, n);
let norm2 = normalize_partition(p2, n);
norm1 == norm2
}
fn normalize_partition(partition: &Partition, n: usize) -> Vec<usize> {
let mut mapping: Vec<usize> = vec![usize::MAX; n];
let mut next_id = 0usize;
let mut result = vec![0usize; n];
for (node, &comm) in partition.as_slice().iter().enumerate() {
if mapping[comm] == usize::MAX {
mapping[comm] = next_id;
next_id += 1;
}
result[node] = mapping[comm];
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::GraphDataBuilder;
fn make_two_cliques() -> GraphData {
let mut b = GraphDataBuilder::new(10);
for i in 0..5 {
for j in (i + 1)..5 {
b.add_edge(i, j, 1.0).unwrap();
}
}
for i in 5..10 {
for j in (i + 1)..10 {
b.add_edge(i, j, 1.0).unwrap();
}
}
b.add_edge(0, 5, 1.0).unwrap();
b.build().unwrap()
}
#[test]
fn test_resolution_scan_two_cliques() {
let data = make_two_cliques();
let entries =
resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 10, Some(42)).unwrap();
assert_eq!(entries.len(), 10);
for i in 1..entries.len() {
assert!(entries[i].resolution > entries[i - 1].resolution);
}
assert!(
entries[0].num_communities >= 1,
"expected at least 1 community at low gamma, got {}",
entries[0].num_communities,
);
let has_two = entries.iter().any(|e| e.num_communities == 2);
assert!(has_two, "expected at least one entry with 2 communities");
for entry in &entries {
assert_eq!(entry.partition.len(), 10);
}
}
#[test]
fn test_resolution_profile_two_cliques() {
let data = make_two_cliques();
let entries =
resolution_profile(&data, QualityType::CPM, (0.1, 2.0), Some(42), 0.01, 0.001).unwrap();
assert!(
!entries.is_empty(),
"profile should produce at least one entry"
);
for i in 1..entries.len() {
assert!(
entries[i].resolution >= entries[i - 1].resolution,
"entries not sorted: {} >= {}",
entries[i].resolution,
entries[i - 1].resolution,
);
}
for entry in &entries {
assert!(entry.resolution >= 0.1 - 1e-10);
assert!(entry.resolution <= 2.0 + 1e-10);
}
for entry in &entries {
assert_eq!(entry.partition.len(), 10);
}
}
#[test]
fn test_resolution_scan_single_point() {
let data = make_two_cliques();
let entries =
resolution_scan(&data, QualityType::Modularity, (1.0, 2.0), 1, Some(42)).unwrap();
assert_eq!(entries.len(), 1);
assert!((entries[0].resolution - 1.0).abs() < 1e-10);
}
#[test]
fn test_resolution_scan_zero_points() {
let data = make_two_cliques();
let entries =
resolution_scan(&data, QualityType::Modularity, (0.1, 2.0), 0, Some(42)).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_partitions_equal_identical() {
let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
assert!(partitions_equal(&p1, &p2, 4));
}
#[test]
fn test_partitions_equal_relabel() {
let p1 = Partition::from_membership(vec![0, 0, 1, 1]);
let p2 = Partition::from_membership(vec![1, 1, 0, 0]);
assert!(partitions_equal(&p1, &p2, 4));
}
#[test]
fn test_partitions_equal_different() {
let p1 = Partition::from_membership(vec![0, 0, 0, 1]);
let p2 = Partition::from_membership(vec![0, 0, 1, 1]);
assert!(!partitions_equal(&p1, &p2, 4));
}
#[test]
fn test_resolution_profile_deduplication() {
let data = make_two_cliques();
let entries = resolution_profile(
&data,
QualityType::CPM,
(0.5, 1.5),
Some(42),
0.01,
1000.0, )
.unwrap();
assert!(
entries.len() <= 3,
"expected heavy deduplication, got {} entries",
entries.len(),
);
}
}