use std::collections::HashMap;
use anyhow::{Context, Result};
use leiden_rs::graph::GraphDataBuilder;
use leiden_rs::hierarchy::HierarchicalOutput;
use leiden_rs::leiden::{Leiden, LeidenConfigBuilder, QualityType};
use rusqlite::Connection;
use crate::db::communities::{delete_all_communities, insert_community, Community};
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EntityNode {
pub id: i64,
pub label: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EntityEdge {
pub source_id: i64,
pub target_id: i64,
pub weight: f64,
}
#[allow(dead_code)]
pub fn load_entity_graph(conn: &Connection) -> Result<(Vec<EntityNode>, Vec<EntityEdge>)> {
let mut node_stmt = conn
.prepare("SELECT id, label FROM nodes WHERE type NOT IN ('note', 'tag') ORDER BY id")
.context("failed to prepare node query")?;
let nodes: Vec<EntityNode> = node_stmt
.query_map([], |row| {
Ok(EntityNode {
id: row.get(0)?,
label: row.get(1)?,
})
})
.context("failed to query entity nodes")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("failed to collect entity nodes")?;
let mut edge_stmt = conn
.prepare("SELECT source_id, target_id, weight FROM edges WHERE type = 'co_occurs_with'")
.context("failed to prepare edge query")?;
let edges: Vec<EntityEdge> = edge_stmt
.query_map([], |row| {
Ok(EntityEdge {
source_id: row.get(0)?,
target_id: row.get(1)?,
weight: row.get(2)?,
})
})
.context("failed to query entity edges")?
.collect::<std::result::Result<Vec<_>, _>>()
.context("failed to collect entity edges")?;
Ok((nodes, edges))
}
#[allow(dead_code)]
pub fn detect_communities(
conn: &Connection,
resolution: f64,
) -> Result<Vec<(i32, Vec<i64>, String)>> {
let (nodes, edges) = load_entity_graph(conn)?;
if nodes.is_empty() {
return Ok(Vec::new());
}
let node_index: HashMap<i64, usize> =
nodes.iter().enumerate().map(|(i, n)| (n.id, i)).collect();
let mut builder = GraphDataBuilder::new(nodes.len());
for edge in &edges {
if let (Some(&src), Some(&dst)) = (
node_index.get(&edge.source_id),
node_index.get(&edge.target_id),
) {
builder
.add_edge(src, dst, edge.weight)
.context("failed to add edge to graph builder")?;
}
}
let graph = builder.build()?;
let config = LeidenConfigBuilder::default()
.resolution(resolution)
.quality(QualityType::CPM)
.build();
let leiden = Leiden::new(config);
let output: HierarchicalOutput = leiden
.run_hierarchical(&graph)
.context("Leiden hierarchical run failed")?;
let mut results = Vec::new();
for (level_idx, level_data) in output.levels.iter().enumerate() {
if level_data.num_communities <= 1 {
continue;
}
let mut communities: HashMap<usize, Vec<i64>> = HashMap::new();
for (node_pos, &community_id) in level_data.membership.iter().enumerate() {
let entity_id = nodes[node_pos].id;
communities.entry(community_id).or_default().push(entity_id);
}
let mut community_entries: Vec<_> = communities.into_iter().collect();
community_entries.sort_by(|a, b| {
let a_min = a.1.iter().min();
let b_min = b.1.iter().min();
a_min.cmp(&b_min)
});
for (comm_idx, (_community_id, member_ids)) in community_entries.iter().enumerate() {
if member_ids.len() >= 2 {
let label = format!("community_{level_idx}_{comm_idx}");
results.push((level_idx as i32, member_ids.clone(), label));
}
}
}
Ok(results)
}
#[allow(dead_code)]
pub fn run_community_detection(db_path: &str, resolution: f64) -> Result<(usize, usize)> {
let conn = Connection::open(db_path).context("failed to open database")?;
let communities = detect_communities(&conn, resolution)?;
delete_all_communities(&conn).context("failed to clear existing communities")?;
for (level, member_ids, label) in &communities {
let community = Community {
id: 0, label: label.clone(),
level: *level,
parent_id: None,
summary: None,
summary_embedding: None,
member_ids: member_ids.clone(),
member_count: member_ids.len() as i32,
algorithm: "leiden".into(),
quality_fn: "cpm".into(),
resolution,
summary_model: None,
embed_model: None,
summary_tokens: None,
};
insert_community(&conn, &community).context("failed to insert community")?;
}
let num_level1 = communities.iter().filter(|(l, _, _)| *l == 1).count();
let num_level2 = communities.iter().filter(|(l, _, _)| *l == 2).count();
Ok((num_level1, num_level2))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::schema::init_db;
fn setup_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
conn
}
fn insert_node(conn: &Connection, id: i64, label: &str, type_: &str) {
let key = if type_ == "note" {
crate::db::keys::note_key(label)
} else {
crate::db::keys::node_key(label)
};
conn.execute(
"INSERT INTO nodes (id, key, label, type, metadata) VALUES (?1, ?2, ?3, ?4, '{}')",
rusqlite::params![id, key, label, type_],
)
.unwrap();
}
fn insert_edge(conn: &Connection, src: i64, dst: i64, weight: f64) {
conn.execute(
"INSERT INTO edges (source_id, target_id, type, weight, context) \
VALUES (?1, ?2, 'co_occurs_with', ?3, 'test')",
rusqlite::params![src, dst, weight],
)
.unwrap();
}
#[test]
fn test_load_entity_graph() {
let conn = setup_db();
insert_node(&conn, 1, "Rust", "language");
insert_node(&conn, 2, "Python", "language");
insert_node(&conn, 3, "Cargo", "tool");
insert_node(&conn, 10, "Some Note", "note");
insert_node(&conn, 11, "Some Tag", "tag");
insert_edge(&conn, 1, 2, 0.8);
insert_edge(&conn, 2, 3, 0.5);
insert_edge(&conn, 1, 10, 0.3);
let (nodes, edges) = load_entity_graph(&conn).expect("load should succeed");
assert_eq!(nodes.len(), 3, "should load 3 entity nodes");
assert_eq!(nodes[0].id, 1);
assert_eq!(nodes[0].label, "Rust");
assert_eq!(nodes[1].id, 2);
assert_eq!(nodes[1].label, "Python");
assert_eq!(nodes[2].id, 3);
assert_eq!(nodes[2].label, "Cargo");
assert_eq!(edges.len(), 3, "should load 3 edges");
}
#[test]
fn test_detect_communities_basic() {
let conn = setup_db();
for i in 1..=6 {
insert_node(&conn, i, &format!("entity_{i}"), "language");
}
insert_edge(&conn, 1, 2, 0.9);
insert_edge(&conn, 1, 3, 0.8);
insert_edge(&conn, 2, 3, 0.7);
insert_edge(&conn, 4, 5, 0.9);
insert_edge(&conn, 4, 6, 0.8);
insert_edge(&conn, 5, 6, 0.7);
insert_edge(&conn, 3, 4, 0.1);
insert_edge(&conn, 2, 5, 0.05);
let results = detect_communities(&conn, 0.5).expect("detect should succeed");
assert!(
!results.is_empty(),
"should detect at least 1 community at resolution 0.5"
);
}
#[test]
fn test_detect_empty_graph() {
let conn = setup_db();
let results = detect_communities(&conn, 1.0).expect("detect should succeed");
assert!(
results.is_empty(),
"empty graph should return no communities"
);
}
#[test]
fn test_run_community_detection_full() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let db_path = tmp.path().to_str().unwrap().to_owned();
{
let conn = Connection::open(&db_path).unwrap();
init_db(&conn).unwrap();
for i in 1..=6 {
insert_node(&conn, i, &format!("entity_{i}"), "language");
}
insert_edge(&conn, 1, 2, 0.9);
insert_edge(&conn, 1, 3, 0.8);
insert_edge(&conn, 2, 3, 0.7);
insert_edge(&conn, 4, 5, 0.9);
insert_edge(&conn, 4, 6, 0.8);
insert_edge(&conn, 5, 6, 0.7);
insert_edge(&conn, 3, 4, 0.1);
insert_edge(&conn, 2, 5, 0.05);
}
let (num_level1, num_level2) =
run_community_detection(&db_path, 0.5).expect("full pipeline should succeed");
let conn = Connection::open(&db_path).unwrap();
let stored = crate::db::communities::get_all_communities(&conn).unwrap();
assert!(
!stored.is_empty(),
"should have stored communities (level1={num_level1}, level2={num_level2})"
);
for community in &stored {
assert!(
community.member_count >= 2,
"community should have 2+ members"
);
assert!(
community.label.starts_with("community_"),
"label should start with 'community_', got '{}'",
community.label
);
}
}
}