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;
16use crate::core::property_graph::GraphSource;
17
18pub mod graph;
19pub mod hardening;
20pub mod leiden;
21pub mod stable_ids;
22#[cfg(test)]
23pub mod tests;
24
25use graph::{AdjGraph, edge_counts};
26
27#[derive(Debug, Clone, Serialize)]
28pub struct Community {
29    pub id: usize,
30    pub files: Vec<String>,
31    pub internal_edges: usize,
32    pub external_edges: usize,
33    pub cohesion: f64,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct CommunityResult {
38    pub communities: Vec<Community>,
39    pub modularity: f64,
40    pub node_count: usize,
41    pub edge_count: usize,
42}
43
44impl CommunityResult {
45    fn empty() -> Self {
46        Self {
47            communities: Vec::new(),
48            modularity: 0.0,
49            node_count: 0,
50            edge_count: 0,
51        }
52    }
53
54    /// `file_path → community_id` for every assigned node.
55    pub fn assignment(&self) -> HashMap<String, usize> {
56        self.assignment_min_size(1)
57    }
58
59    /// `file_path → community_id`, restricted to communities with at least
60    /// `min_size` members. Singletons (isolated files) are dropped so the
61    /// dashboard can fall back to a neutral/language colour instead of painting
62    /// every orphan file a distinct hue.
63    pub fn assignment_min_size(&self, min_size: usize) -> HashMap<String, usize> {
64        let mut map = HashMap::new();
65        for community in &self.communities {
66            if community.files.len() < min_size {
67                continue;
68            }
69            for file in &community.files {
70                map.insert(file.clone(), community.id);
71            }
72        }
73        map
74    }
75}
76
77/// Detect communities on the PropertyGraph (ids are deterministic but not
78/// remapped to a previous run). Prefer [`detect_communities_stable`] when a
79/// project root is available.
80pub fn detect_communities(conn: &Connection) -> CommunityResult {
81    let graph = AdjGraph::from_property_graph(conn);
82    analyze(&graph, None).1
83}
84
85/// Detect communities on the PropertyGraph with ids kept stable across rebuilds
86/// (remapped to, and persisted alongside, the project's previous assignment).
87///
88/// Pass the opened `CodeGraph` for a result that is served: the partition and
89/// every number in the result are then computed over the files served now
90/// (`AdjGraph::from_property_graph`). A bare connection reads the raw store.
91pub fn detect_communities_stable<S: GraphSource + ?Sized>(
92    source: &S,
93    project_root: &str,
94) -> CommunityResult {
95    let graph = AdjGraph::from_property_graph(source);
96    detect_stable(&graph, project_root)
97}
98
99/// Detect communities on any [`GraphProvider`] (PropertyGraph or graph index)
100/// with stable ids. This is the entry point for the dashboard graph view.
101pub fn detect_communities_for_provider(gp: &GraphProvider, project_root: &str) -> CommunityResult {
102    let graph = AdjGraph::from_provider(gp);
103    detect_stable(&graph, project_root)
104}
105
106fn detect_stable(graph: &AdjGraph, project_root: &str) -> CommunityResult {
107    if graph.node_count() == 0 {
108        return CommunityResult::empty();
109    }
110    // The persisted assignment is itself derived from the sources of an
111    // earlier run. Only the entries of files in today's graph are used; it is
112    // rewritten below from today's graph, so a withdrawn file leaves it.
113    let previous = stable_ids::load_previous(project_root).map(|mut previous| {
114        previous.retain(|file, _| graph.node_to_idx.contains_key(file));
115        previous
116    });
117    let (assignment, result) = analyze(graph, previous.as_ref());
118    stable_ids::save_assignment(project_root, &graph.node_ids, &assignment);
119    result
120}
121
122/// Full pipeline: partition (hub-aware) → resplit → canonicalize → optional
123/// remap to the previous assignment. Returns the final per-node assignment and
124/// the presentation-ready result.
125fn analyze(
126    graph: &AdjGraph,
127    previous: Option<&HashMap<String, usize>>,
128) -> (Vec<usize>, CommunityResult) {
129    let n = graph.node_count();
130    if n == 0 {
131        return (Vec::new(), CommunityResult::empty());
132    }
133
134    let mut assignment = hardening::partition_with_hub_exclusion(graph);
135    hardening::split_oversized_and_incohesive(graph, &mut assignment);
136    let mut assignment = stable_ids::canonicalize(graph, &assignment);
137    if let Some(prev) = previous
138        && !prev.is_empty()
139    {
140        assignment = stable_ids::remap_to_previous(graph, &assignment, prev);
141    }
142
143    let result = build_result(graph, &assignment);
144    (assignment, result)
145}
146
147fn build_result(graph: &AdjGraph, assignment: &[usize]) -> CommunityResult {
148    let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
149    for (i, &c) in assignment.iter().enumerate() {
150        groups.entry(c).or_default().push(i);
151    }
152
153    let mut communities: Vec<Community> = groups
154        .into_iter()
155        .map(|(id, mut members)| {
156            members.sort_unstable();
157            let (internal, external) = edge_counts(graph, &members);
158            let total = (internal + external).max(1) as f64;
159            Community {
160                id,
161                files: members.iter().map(|&i| graph.node_ids[i].clone()).collect(),
162                internal_edges: internal,
163                external_edges: external,
164                cohesion: internal as f64 / total,
165            }
166        })
167        .collect();
168
169    // Largest, most cohesive communities first; ids stay meaningful/stable.
170    communities.sort_by(|a, b| {
171        b.files
172            .len()
173            .cmp(&a.files.len())
174            .then(
175                b.cohesion
176                    .partial_cmp(&a.cohesion)
177                    .unwrap_or(std::cmp::Ordering::Equal),
178            )
179            .then(a.id.cmp(&b.id))
180    });
181
182    CommunityResult {
183        communities,
184        modularity: leiden::compute_modularity(graph, assignment),
185        node_count: graph.node_count(),
186        edge_count: graph.edge_count(),
187    }
188}