use alloc::vec::Vec;
use num_traits::{AsPrimitive, ToPrimitive};
use super::modularity::ModularityError;
use crate::traits::{Finite, Number, SparseValuedMatrix2D};
pub(crate) struct DirectedView<'matrix, M: SparseValuedMatrix2D> {
matrix: &'matrix M,
row_handles: Vec<M::RowIndex>,
pub(crate) out_degree: Vec<f64>,
pub(crate) in_degree: Vec<f64>,
pub(crate) total_weight: f64,
}
impl<'matrix, M> DirectedView<'matrix, M>
where
M: SparseValuedMatrix2D,
M::RowIndex: AsPrimitive<usize>,
M::ColumnIndex: AsPrimitive<usize>,
M::Value: ToPrimitive + Finite,
{
pub(crate) fn new(matrix: &'matrix M) -> Result<Self, ModularityError> {
let rows = matrix.number_of_rows().as_();
let columns = matrix.number_of_columns().as_();
if rows != columns {
return Err(ModularityError::NonSquareMatrix { rows, columns });
}
let mut row_handles = Vec::with_capacity(rows);
let mut out_degree = vec![0.0; rows];
let mut in_degree = vec![0.0; rows];
for row_id in matrix.row_indices() {
let source = row_id.as_();
debug_assert_eq!(source, row_handles.len(), "node ids must be contiguous and ordered");
row_handles.push(row_id);
for (column_id, weight) in
matrix.sparse_row(row_id).zip(matrix.sparse_row_values(row_id))
{
let destination = column_id.as_();
if !weight.is_finite() {
return Err(ModularityError::NonFiniteWeight {
source_id: source,
destination_id: destination,
});
}
let weight = weight.to_f64().ok_or(ModularityError::UnrepresentableWeight {
source_id: source,
destination_id: destination,
})?;
if !weight.is_finite() {
return Err(ModularityError::NonFiniteWeight {
source_id: source,
destination_id: destination,
});
}
if weight <= 0.0 {
return Err(ModularityError::NonPositiveWeight {
source_id: source,
destination_id: destination,
});
}
out_degree[source] += weight;
in_degree[destination] += weight;
}
}
let total_weight = out_degree.iter().sum();
Ok(Self { matrix, row_handles, out_degree, in_degree, total_weight })
}
pub(crate) fn number_of_nodes(&self) -> usize {
self.row_handles.len()
}
pub(crate) fn for_each_out_edge<F: FnMut(usize, f64)>(&self, node: usize, mut visit: F) {
let handle = self.row_handles[node];
for (column, value) in
self.matrix.sparse_row(handle).zip(self.matrix.sparse_row_values(handle))
{
if let Some(weight) = value.to_f64() {
visit(column.as_(), weight);
}
}
}
pub(crate) fn modularity(&self, partition: &[usize], resolution: f64) -> f64 {
if self.total_weight <= 0.0 || !self.total_weight.is_normal() {
return 0.0;
}
let number_of_communities = partition.iter().copied().max().map_or(0, |max| max + 1);
let mut internal_weight = vec![0.0; number_of_communities];
let mut out_strength = vec![0.0; number_of_communities];
let mut in_strength = vec![0.0; number_of_communities];
for (node, community) in partition.iter().copied().enumerate() {
out_strength[community] += self.out_degree[node];
in_strength[community] += self.in_degree[node];
self.for_each_out_edge(node, |destination, weight| {
if partition[destination] == community {
internal_weight[community] += weight;
}
});
}
let inverse_total_weight = 1.0 / self.total_weight;
(0..number_of_communities).fold(0.0, |modularity, community| {
let internal_fraction = internal_weight[community] * inverse_total_weight;
let expected_fraction = resolution
* (out_strength[community] * inverse_total_weight)
* (in_strength[community] * inverse_total_weight);
modularity + internal_fraction - expected_fraction
})
}
}
pub trait DirectedModularity<Marker: AsPrimitive<usize> = usize>:
SparseValuedMatrix2D + Sized
where
Self::RowIndex: AsPrimitive<usize>,
Self::ColumnIndex: AsPrimitive<usize>,
Self::Value: Number + ToPrimitive + Finite,
{
#[inline]
fn directed_modularity(
&self,
partition: &[Marker],
resolution: f64,
) -> Result<f64, ModularityError> {
if !resolution.is_finite() || resolution <= 0.0 {
return Err(ModularityError::InvalidResolution);
}
let view = DirectedView::new(self)?;
let number_of_nodes = view.number_of_nodes();
if partition.len() != number_of_nodes {
return Err(ModularityError::PartitionLengthMismatch {
expected: number_of_nodes,
found: partition.len(),
});
}
let usize_partition: Vec<usize> =
partition.iter().map(|community| community.as_()).collect();
Ok(view.modularity(&usize_partition, resolution))
}
}
impl<G, Marker> DirectedModularity<Marker> for G
where
G: SparseValuedMatrix2D + Sized,
Marker: AsPrimitive<usize>,
G::RowIndex: AsPrimitive<usize>,
G::ColumnIndex: AsPrimitive<usize>,
G::Value: Number + ToPrimitive + Finite,
{
}
#[cfg(test)]
mod tests {
use alloc::vec::Vec;
use super::{DirectedModularity, DirectedView, ModularityError};
use crate::{impls::ValuedCSR2D, naive_structs::GenericEdgesBuilder, traits::EdgesBuilder};
type WeightedMatrix = ValuedCSR2D<usize, usize, usize, f64>;
const TOLERANCE: f64 = 1e-12;
fn build(node_count: usize, edges: Vec<(usize, usize, f64)>) -> WeightedMatrix {
let mut edges = edges;
edges.sort_unstable_by(|(ls, ld, _), (rs, rd, _)| (ls, ld).cmp(&(rs, rd)));
GenericEdgesBuilder::<_, WeightedMatrix>::default()
.expected_number_of_edges(edges.len())
.expected_shape((node_count, node_count))
.edges(edges.into_iter())
.build()
.unwrap()
}
fn modularity(matrix: &WeightedMatrix, partition: &[usize], resolution: f64) -> f64 {
DirectedView::new(matrix).unwrap().modularity(partition, resolution)
}
#[test]
fn test_single_directed_edge_is_zero_for_both_partitions() {
let matrix = build(2, vec![(0, 1, 1.0)]);
assert!(modularity(&matrix, &[0, 0], 1.0).abs() < TOLERANCE);
assert!(modularity(&matrix, &[0, 1], 1.0).abs() < TOLERANCE);
}
#[test]
fn test_bidirectional_pair_single_community_is_zero() {
let matrix = build(2, vec![(0, 1, 1.0), (1, 0, 1.0)]);
assert!(modularity(&matrix, &[0, 0], 1.0).abs() < TOLERANCE);
assert!((modularity(&matrix, &[0, 1], 1.0) - (-0.5)).abs() < TOLERANCE);
}
#[test]
fn test_two_disjoint_directed_cycles() {
let matrix = build(4, vec![(0, 1, 1.0), (1, 0, 1.0), (2, 3, 1.0), (3, 2, 1.0)]);
assert!((modularity(&matrix, &[0, 0, 1, 1], 1.0) - 0.5).abs() < TOLERANCE);
assert!(modularity(&matrix, &[0, 0, 0, 0], 1.0).abs() < TOLERANCE);
}
#[test]
fn test_self_loop_is_handled() {
let matrix = build(2, vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0)]);
assert!(modularity(&matrix, &[0, 0], 1.0).abs() < TOLERANCE);
assert!((modularity(&matrix, &[0, 1], 1.0) - (-0.125)).abs() < TOLERANCE);
}
#[test]
fn test_resolution_scales_expected_term() {
let matrix = build(4, vec![(0, 1, 1.0), (1, 0, 1.0), (2, 3, 1.0), (3, 2, 1.0)]);
assert!(modularity(&matrix, &[0, 0, 1, 1], 2.0).abs() < TOLERANCE);
}
#[test]
fn test_trait_matches_view_and_validates_length() {
let matrix = build(4, vec![(0, 1, 1.0), (1, 0, 1.0), (2, 3, 1.0), (3, 2, 1.0)]);
let q =
DirectedModularity::<usize>::directed_modularity(&matrix, &[0, 0, 1, 1], 1.0).unwrap();
assert!((q - 0.5).abs() < TOLERANCE);
let error =
DirectedModularity::<usize>::directed_modularity(&matrix, &[0, 0, 1], 1.0).unwrap_err();
assert!(matches!(
error,
ModularityError::PartitionLengthMismatch { expected: 4, found: 3 }
));
}
#[test]
fn test_trait_rejects_invalid_resolution() {
let matrix = build(2, vec![(0, 1, 1.0), (1, 0, 1.0)]);
let error =
DirectedModularity::<usize>::directed_modularity(&matrix, &[0, 0], 0.0).unwrap_err();
assert!(matches!(error, ModularityError::InvalidResolution));
}
#[test]
fn test_view_rejects_non_square() {
let matrix: WeightedMatrix = GenericEdgesBuilder::<_, WeightedMatrix>::default()
.expected_number_of_edges(1)
.expected_shape((2, 3))
.edges(vec![(0, 1, 1.0)].into_iter())
.build()
.unwrap();
assert!(matches!(
DirectedView::new(&matrix),
Err(ModularityError::NonSquareMatrix { rows: 2, columns: 3 })
));
}
#[test]
fn test_view_rejects_non_positive_weight() {
let matrix = build(2, vec![(0, 1, -1.0)]);
assert!(matches!(
DirectedView::new(&matrix),
Err(ModularityError::NonPositiveWeight { .. })
));
}
#[test]
fn test_view_keeps_directed_degrees_separate() {
let matrix = build(2, vec![(0, 1, 3.0)]);
let view = DirectedView::new(&matrix).unwrap();
assert_eq!(view.out_degree, vec![3.0, 0.0]);
assert_eq!(view.in_degree, vec![0.0, 3.0]);
assert!((view.total_weight - 3.0).abs() < TOLERANCE);
}
}