Skip to main content

lean_ctx/core/community/
mod.rs

1//! Community detection on the code graph.
2//!
3//! A hardened Leiden engine (`leiden`) clusters files into cohesive modules,
4//! with robustness passes for super-hubs and oversized/low-cohesion communities
5//! (`hardening`) and **stable ids across rebuilds** (`stable_ids`). The
6//! engine is storage-agnostic: it runs on the PropertyGraph (SQLite) and on any
7//! [`GraphProvider`], so `ctx_architecture` and the dashboard graph view share
8//! one implementation and report identical community ids.
9
10use std::collections::HashMap;
11
12use rusqlite::Connection;
13use serde::Serialize;
14
15use crate::core::graph_provider::GraphProvider;
16
17mod graph;
18mod hardening;
19mod leiden;
20mod stable_ids;
21#[cfg(test)]
22mod tests;
23
24use graph::{edge_counts, AdjGraph};
25
26#[derive(Debug, Clone, Serialize)]
27pub struct Community {
28    pub id: usize,
29    pub files: Vec<String>,
30    pub internal_edges: usize,
31    pub external_edges: usize,
32    pub cohesion: f64,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct CommunityResult {
37    pub communities: Vec<Community>,
38    pub modularity: f64,
39    pub node_count: usize,
40    pub edge_count: usize,
41}
42
43impl CommunityResult {
44    fn empty() -> Self {
45        Self {
46            communities: Vec::new(),
47            modularity: 0.0,
48            node_count: 0,
49            edge_count: 0,
50        }
51    }
52
53    /// `file_path → community_id` for every assigned node.
54    pub fn assignment(&self) -> HashMap<String, usize> {
55        self.assignment_min_size(1)
56    }
57
58    /// `file_path → community_id`, restricted to communities with at least
59    /// `min_size` members. Singletons (isolated files) are dropped so the
60    /// dashboard can fall back to a neutral/language colour instead of painting
61    /// every orphan file a distinct hue.
62    pub fn assignment_min_size(&self, min_size: usize) -> HashMap<String, usize> {
63        let mut map = HashMap::new();
64        for community in &self.communities {
65            if community.files.len() < min_size {
66                continue;
67            }
68            for file in &community.files {
69                map.insert(file.clone(), community.id);
70            }
71        }
72        map
73    }
74}
75
76/// Detect communities on the PropertyGraph (ids are deterministic but not
77/// remapped to a previous run). Prefer [`detect_communities_stable`] when a
78/// project root is available.
79pub fn detect_communities(conn: &Connection) -> CommunityResult {
80    let graph = AdjGraph::from_property_graph(conn);
81    analyze(&graph, None).1
82}
83
84/// Detect communities on the PropertyGraph with ids kept stable across rebuilds
85/// (remapped to, and persisted alongside, the project's previous assignment).
86pub fn detect_communities_stable(conn: &Connection, project_root: &str) -> CommunityResult {
87    let graph = AdjGraph::from_property_graph(conn);
88    detect_stable(&graph, project_root)
89}
90
91/// Detect communities on any [`GraphProvider`] (PropertyGraph or graph index)
92/// with stable ids. This is the entry point for the dashboard graph view.
93pub fn detect_communities_for_provider(gp: &GraphProvider, project_root: &str) -> CommunityResult {
94    let graph = AdjGraph::from_provider(gp);
95    detect_stable(&graph, project_root)
96}
97
98fn detect_stable(graph: &AdjGraph, project_root: &str) -> CommunityResult {
99    if graph.node_count() == 0 {
100        return CommunityResult::empty();
101    }
102    let previous = stable_ids::load_previous(project_root);
103    let (assignment, result) = analyze(graph, previous.as_ref());
104    stable_ids::save_assignment(project_root, &graph.node_ids, &assignment);
105    result
106}
107
108/// Full pipeline: partition (hub-aware) → resplit → canonicalize → optional
109/// remap to the previous assignment. Returns the final per-node assignment and
110/// the presentation-ready result.
111fn analyze(
112    graph: &AdjGraph,
113    previous: Option<&HashMap<String, usize>>,
114) -> (Vec<usize>, CommunityResult) {
115    let n = graph.node_count();
116    if n == 0 {
117        return (Vec::new(), CommunityResult::empty());
118    }
119
120    let mut assignment = hardening::partition_with_hub_exclusion(graph);
121    hardening::split_oversized_and_incohesive(graph, &mut assignment);
122    let mut assignment = stable_ids::canonicalize(graph, &assignment);
123    if let Some(prev) = previous {
124        if !prev.is_empty() {
125            assignment = stable_ids::remap_to_previous(graph, &assignment, prev);
126        }
127    }
128
129    let result = build_result(graph, &assignment);
130    (assignment, result)
131}
132
133fn build_result(graph: &AdjGraph, assignment: &[usize]) -> CommunityResult {
134    let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
135    for (i, &c) in assignment.iter().enumerate() {
136        groups.entry(c).or_default().push(i);
137    }
138
139    let mut communities: Vec<Community> = groups
140        .into_iter()
141        .map(|(id, mut members)| {
142            members.sort_unstable();
143            let (internal, external) = edge_counts(graph, &members);
144            let total = (internal + external).max(1) as f64;
145            Community {
146                id,
147                files: members.iter().map(|&i| graph.node_ids[i].clone()).collect(),
148                internal_edges: internal,
149                external_edges: external,
150                cohesion: internal as f64 / total,
151            }
152        })
153        .collect();
154
155    // Largest, most cohesive communities first; ids stay meaningful/stable.
156    communities.sort_by(|a, b| {
157        b.files
158            .len()
159            .cmp(&a.files.len())
160            .then(
161                b.cohesion
162                    .partial_cmp(&a.cohesion)
163                    .unwrap_or(std::cmp::Ordering::Equal),
164            )
165            .then(a.id.cmp(&b.id))
166    });
167
168    CommunityResult {
169        communities,
170        modularity: leiden::compute_modularity(graph, assignment),
171        node_count: graph.node_count(),
172        edge_count: graph.edge_count(),
173    }
174}