Files
poimen-memory/crates/mem-cli/src/query/community_detector.rs
T
rock 05a60fc20b
CI / CI (pull_request) Successful in 11m9s
fix: resolve integration test compilation + CI errors
Test compilation fixes (8 integration test files):
  1. Ambiguous float types — added f32/f64 annotations
  2. chrono API — replaced with_hour() with date_naive().and_hms_opt()
  3. Missing dev-dependencies — added sqlx + base64
  4. Generic parse — wrapped f32 comparison in parens
  5. Incorrect assertion — 3^5=243 > 100, changed nodes to 1000

CI fixes:
  6. Missing benchmark fixtures — created 3 files in fixtures/benchmarks/
  7. clippy absurd_extreme_comparisons — usize >= 0 always true
  8. authentik_jwt test — Option<SystemTime> type mismatch
  9. http_server tests — removed broken RBAC test module (types deleted)

Result: cargo build --all clean, cargo test --all --lib passes
2026-09-08 18:08:18 -07:00

346 lines
13 KiB
Rust

//! Community Detection Engine
//!
//! Detects entity clusters using Louvain algorithm with modularity optimization.
//! Used to identify topic areas, entity groupings, and knowledge graph structure.
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use std::collections::{HashMap, HashSet};
use tracing::{debug, info};
/// A detected community (cluster of related entities)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Community {
pub id: usize,
pub entity_ids: Vec<String>,
pub entity_names: Vec<String>,
pub size: usize,
pub modularity_contribution: f32, // This community's contribution to total modularity
pub average_strength: f32, // Average relationship strength within community
pub density: f32, // 0-1, how tightly connected (actual edges / possible edges)
}
/// Results from community detection
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityDetectionResult {
pub entity_count: usize,
pub edge_count: usize,
pub communities: Vec<Community>,
pub community_count: usize,
pub total_modularity: f32, // Overall modularity score (-1 to 1, higher is better)
pub average_community_size: f32,
}
/// Edge representation for community detection
#[derive(Debug, Clone)]
struct GraphEdge {
source: String,
target: String,
weight: f32, // Relationship strength (0-1)
}
/// Community Detector using Louvain algorithm
pub struct CommunityDetector {
pub pool: Pool<Postgres>,
}
impl CommunityDetector {
/// Create a new community detector
pub fn new(pool: Pool<Postgres>) -> Self {
Self { pool }
}
/// Detect communities in the knowledge graph
///
/// Uses Louvain algorithm to partition entities into communities
/// based on relationship strength and graph structure.
///
/// # Arguments
/// * `project_id` - Project to analyze (optional, analyze all if None)
/// * `min_community_size` - Minimum entities per community (default 3, min 2)
/// * `modularity_threshold` - Stop optimization when improvement < threshold (default 0.001)
///
/// # Returns
/// CommunityDetectionResult with detected communities and metrics
pub async fn detect_communities(
&self,
project_id: Option<&str>,
min_community_size: usize,
modularity_threshold: f32,
) -> Result<CommunityDetectionResult, String> {
let min_community_size = min_community_size.max(2).min(1000);
let modularity_threshold = modularity_threshold.max(0.0001).min(0.1);
debug!(
"Detecting communities: project={:?}, min_size={}, threshold={}",
project_id, min_community_size, modularity_threshold
);
// 1. Fetch entities and edges from database
let (entities, edges) = self.fetch_graph(project_id).await?;
if entities.is_empty() {
return Ok(CommunityDetectionResult {
entity_count: 0,
edge_count: 0,
communities: vec![],
community_count: 0,
total_modularity: 0.0,
average_community_size: 0.0,
});
}
// 2. Initialize: each entity is its own community
let mut entity_to_community: HashMap<String, usize> = HashMap::new();
let mut community_members: HashMap<usize, HashSet<String>> = HashMap::new();
for (idx, entity_id) in entities.iter().enumerate() {
entity_to_community.insert(entity_id.clone(), idx);
let mut members = HashSet::new();
members.insert(entity_id.clone());
community_members.insert(idx, members);
}
// 3. Louvain algorithm: iteratively optimize modularity
let mut improved = true;
let mut iteration = 0;
let max_iterations = 100;
while improved && iteration < max_iterations {
improved = false;
iteration += 1;
// Try moving each entity to neighboring communities
for entity_id in &entities {
let current_community = entity_to_community[entity_id];
let mut best_community = current_community;
let mut best_modularity_gain = 0.0;
// Find neighboring communities (connected via edges)
let mut neighbor_communities = HashSet::new();
neighbor_communities.insert(current_community);
for edge in &edges {
if edge.source == *entity_id {
if let Some(&comm) = entity_to_community.get(&edge.target) {
neighbor_communities.insert(comm);
}
} else if edge.target == *entity_id {
if let Some(&comm) = entity_to_community.get(&edge.source) {
neighbor_communities.insert(comm);
}
}
}
// Evaluate moving to each neighbor community
for &test_community in &neighbor_communities {
let gain = self.calculate_modularity_gain(
entity_id,
current_community,
test_community,
&edges,
&entity_to_community,
);
if gain > best_modularity_gain {
best_modularity_gain = gain;
best_community = test_community;
}
}
// Move entity if better community found
if best_community != current_community && best_modularity_gain > modularity_threshold {
entity_to_community.insert(entity_id.clone(), best_community);
// Update community membership
community_members
.get_mut(&current_community)
.map(|m| m.remove(entity_id));
community_members
.entry(best_community)
.or_insert_with(HashSet::new)
.insert(entity_id.clone());
improved = true;
}
}
}
// 4. Convert communities to output format
let mut communities_vec = Vec::new();
for (comm_id, members) in community_members {
if members.len() >= min_community_size {
let entity_names = members
.iter()
.map(|id| id.clone()) // In production, would look up actual names
.collect();
let strength = self.calculate_community_strength(&members, &edges);
let density = self.calculate_community_density(&members, &edges);
let modularity_contrib = self.calculate_modularity_contribution(
&members,
&edges,
&entity_to_community,
);
communities_vec.push(Community {
id: comm_id,
size: members.len(),
entity_ids: members.into_iter().collect(),
entity_names,
modularity_contribution: modularity_contrib,
average_strength: strength,
density,
});
}
}
// 5. Calculate total modularity
let total_modularity: f64 = communities_vec
.iter()
.map(|c| c.modularity_contribution as f64)
.sum();
let average_community_size = if communities_vec.is_empty() {
0.0
} else {
communities_vec.iter().map(|c| c.size as f32).sum::<f32>() / communities_vec.len() as f32
};
let result = CommunityDetectionResult {
entity_count: entities.len(),
edge_count: edges.len(),
community_count: communities_vec.len(),
communities: communities_vec,
total_modularity: total_modularity.max(-1.0).min(1.0) as f32,
average_community_size,
};
info!(
"Community detection complete: {} communities, modularity={}",
result.community_count, result.total_modularity
);
Ok(result)
}
/// Fetch entities and edges from database
async fn fetch_graph(&self, _project_id: Option<&str>) -> Result<(Vec<String>, Vec<GraphEdge>), String> {
// Fetch entities
let entities = sqlx::query_as::<_, (String,)>(
"SELECT DISTINCT id FROM memory_entity WHERE deleted_at IS NULL"
)
.fetch_all(&self.pool)
.await
.map_err(|e| format!("Failed to fetch entities: {}", e))?
.into_iter()
.map(|(id,)| id)
.collect();
// Fetch edges with confidence as weight
let edges = sqlx::query_as::<_, (String, String, f32)>(
"SELECT source_entity_id, target_entity_id, confidence
FROM memory_edge
WHERE fact_invalid_at IS NULL AND deleted_at IS NULL"
)
.fetch_all(&self.pool)
.await
.map_err(|e| format!("Failed to fetch edges: {}", e))?
.into_iter()
.map(|(source, target, confidence)| GraphEdge {
source,
target,
weight: confidence.max(0.0).min(1.0),
})
.collect();
Ok((entities, edges))
}
/// Calculate modularity gain of moving entity to target community
fn calculate_modularity_gain(
&self,
entity_id: &str,
from_community: usize,
to_community: usize,
edges: &[GraphEdge],
entity_to_community: &HashMap<String, usize>,
) -> f32 {
// Simplified modularity gain calculation
// In production, use full Louvain formula with degrees
let mut connections_to_target = 0.0;
let mut connections_to_current = 0.0;
for edge in edges {
if edge.source == entity_id && entity_to_community.get(&edge.target).copied() == Some(to_community) {
connections_to_target += edge.weight;
} else if edge.target == entity_id && entity_to_community.get(&edge.source).copied() == Some(to_community) {
connections_to_target += edge.weight;
}
if edge.source == entity_id && entity_to_community.get(&edge.target).copied() == Some(from_community) {
connections_to_current += edge.weight;
} else if edge.target == entity_id && entity_to_community.get(&edge.source).copied() == Some(from_community) {
connections_to_current += edge.weight;
}
}
// Gain = increased connections to target - lost connections from current
(connections_to_target - connections_to_current) / edges.len().max(1) as f32
}
/// Calculate average relationship strength within community
fn calculate_community_strength(&self, members: &HashSet<String>, edges: &[GraphEdge]) -> f32 {
let mut total_weight = 0.0;
let mut count = 0;
for edge in edges {
if members.contains(&edge.source) && members.contains(&edge.target) {
total_weight += edge.weight;
count += 1;
}
}
if count == 0 {
0.0
} else {
(total_weight / count as f32).max(0.0).min(1.0)
}
}
/// Calculate community density (actual edges / possible edges)
fn calculate_community_density(&self, members: &HashSet<String>, edges: &[GraphEdge]) -> f32 {
let n = members.len() as f32;
let possible_edges = (n * (n - 1.0) / 2.0).max(1.0);
let mut actual_edges = 0.0;
for edge in edges {
if members.contains(&edge.source) && members.contains(&edge.target) {
actual_edges += 1.0;
}
}
(actual_edges / possible_edges).max(0.0).min(1.0)
}
/// Calculate this community's contribution to total modularity
fn calculate_modularity_contribution(
&self,
members: &HashSet<String>,
edges: &[GraphEdge],
_entity_to_community: &HashMap<String, usize>,
) -> f32 {
let internal_edges: f32 = edges
.iter()
.filter(|e| members.contains(&e.source) && members.contains(&e.target))
.map(|e| e.weight)
.sum();
// Simplified: normalized by community size
let max_possible = (members.len() as f32 * (members.len() as f32 - 1.0) / 2.0).max(1.0);
(internal_edges / max_possible).max(0.0).min(1.0)
}
}