Skip to main content

facett_graphview/
community.rs

1//! **Topological semantic zoom** — community detection → meta-nodes → fracture (#3).
2//!
3//! Zooming out of a large graph should *consolidate*, not shrink-to-illegible. We
4//! detect the graph's **communities** (densely-connected clusters) with **Louvain**
5//! modularity optimisation, then drive a zoom-aware collapse: at low zoom each
6//! community becomes one consolidated **meta-node**; as the user zooms in past a
7//! threshold the meta-node **fractures** open — its members ease out from the
8//! meta-centroid to their real positions under an **injected-clock easing** curve
9//! (the same dependency-injected easing seam `facett-helix` uses), so the structure
10//! blooms apart instead of popping.
11//!
12//! # What lands here vs the roadmap
13//! This is one solid level of Louvain (**local-moving** modularity ascent to a fixed
14//! point) — deterministic (fixed node order, lowest-index tie-break), enough to
15//! detect clear communities and drive the meta-node/fracture pipeline. The recursive
16//! **multi-level aggregation** (Louvain proper) and the **Leiden** refinement
17//! (guaranteed well-connected communities) are documented in
18//! `.nornir/elite-graph-engine.md`; they slot in behind [`louvain`] without changing
19//! the meta-graph / fracture API.
20
21use std::collections::BTreeMap;
22
23/// A detected partition: `of[i]` is node `i`'s community (compact `0..count`), with
24/// the partition's **modularity** as the quality DATA.
25#[derive(Clone, Debug, PartialEq)]
26pub struct Communities {
27    pub of: Vec<usize>,
28    pub count: usize,
29    pub modularity: f32,
30}
31
32/// **Louvain local-moving** community detection over an undirected, unweighted graph
33/// of `n` nodes. Greedily moves each node to the neighbouring community with the best
34/// modularity gain until a full pass makes no move. Deterministic in `(n, edges)`.
35#[must_use]
36pub fn louvain(n: usize, edges: &[(usize, usize)]) -> Communities {
37    // ── build undirected adjacency + degrees (drop self-loops / out-of-range) ──
38    let mut adj: Vec<BTreeMap<usize, f32>> = vec![BTreeMap::new(); n];
39    let mut deg = vec![0.0f32; n];
40    let mut m2 = 0.0f32; // 2·m (sum of degrees)
41    for &(a, b) in edges {
42        if a == b || a >= n || b >= n {
43            continue;
44        }
45        *adj[a].entry(b).or_insert(0.0) += 1.0;
46        *adj[b].entry(a).or_insert(0.0) += 1.0;
47    }
48    for i in 0..n {
49        deg[i] = adj[i].values().sum();
50        m2 += deg[i];
51    }
52    if m2 == 0.0 {
53        // edgeless: every node is its own community.
54        return Communities { of: (0..n).collect(), count: n, modularity: 0.0 };
55    }
56
57    let mut comm: Vec<usize> = (0..n).collect();
58    let mut sigma_tot: Vec<f32> = deg.clone(); // total degree per community
59
60    loop {
61        let mut moved = false;
62        for i in 0..n {
63            let ci = comm[i];
64            // remove i from its community.
65            sigma_tot[ci] -= deg[i];
66            // weight from i into each candidate community.
67            let mut k_in: BTreeMap<usize, f32> = BTreeMap::new();
68            for (&j, &w) in &adj[i] {
69                if j != i {
70                    *k_in.entry(comm[j]).or_insert(0.0) += w;
71                }
72            }
73            // best gain: k_{i,in}(C) − Σ_tot(C)·k_i / (2m). Staying is the baseline.
74            let mut best_c = ci;
75            let mut best_gain = k_in.get(&ci).copied().unwrap_or(0.0) - sigma_tot[ci] * deg[i] / m2;
76            for (&c, &kin) in &k_in {
77                let gain = kin - sigma_tot[c] * deg[i] / m2;
78                if gain > best_gain + 1e-9 {
79                    best_gain = gain;
80                    best_c = c;
81                }
82            }
83            sigma_tot[best_c] += deg[i];
84            if best_c != ci {
85                comm[i] = best_c;
86                moved = true;
87            }
88        }
89        if !moved {
90            break;
91        }
92    }
93
94    // ── compact labels 0..count, then score modularity ──
95    let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
96    let of: Vec<usize> = comm
97        .iter()
98        .map(|&c| {
99            let next = remap.len();
100            *remap.entry(c).or_insert(next)
101        })
102        .collect();
103    let count = remap.len();
104    let modularity = modularity(n, edges, &of);
105    Communities { of, count, modularity }
106}
107
108/// Newman modularity `Q = (1/2m) Σ_ij [A_ij − k_i k_j/2m] δ(c_i,c_j)` of a partition.
109#[must_use]
110pub fn modularity(n: usize, edges: &[(usize, usize)], of: &[usize]) -> f32 {
111    let mut deg = vec![0.0f32; n];
112    let mut m2 = 0.0f32;
113    let mut a_in: BTreeMap<(usize, usize), f32> = BTreeMap::new();
114    for &(a, b) in edges {
115        if a == b || a >= n || b >= n {
116            continue;
117        }
118        deg[a] += 1.0;
119        deg[b] += 1.0;
120        m2 += 2.0;
121        *a_in.entry((a.min(b), a.max(b))).or_insert(0.0) += 1.0;
122    }
123    if m2 == 0.0 {
124        return 0.0;
125    }
126    let mut q = 0.0f32;
127    // intra-community A_ij contributions (each undirected edge counted twice in the sum).
128    for (&(a, b), &w) in &a_in {
129        if of[a] == of[b] {
130            q += 2.0 * w;
131        }
132    }
133    // − Σ k_i k_j / 2m over same-community pairs.
134    let mut by_comm: BTreeMap<usize, f32> = BTreeMap::new();
135    for i in 0..n {
136        *by_comm.entry(of[i]).or_insert(0.0) += deg[i];
137    }
138    let mut expected = 0.0f32;
139    for &s in by_comm.values() {
140        expected += s * s / m2;
141    }
142    (q - expected) / m2
143}
144
145/// One **meta-node**: a collapsed community — its members, centroid position, and a
146/// weight (member count) a renderer can size by.
147#[derive(Clone, Debug, PartialEq)]
148pub struct MetaNode {
149    pub community: usize,
150    pub members: Vec<usize>,
151    pub pos: [f32; 2],
152    pub weight: f32,
153}
154
155/// The collapsed graph: one [`MetaNode`] per community + aggregated inter-community
156/// edges (`from_comm`, `to_comm`, `weight = crossing-edge count`).
157#[derive(Clone, Debug, PartialEq)]
158pub struct MetaGraph {
159    pub metas: Vec<MetaNode>,
160    pub edges: Vec<(usize, usize, f32)>,
161}
162
163/// Collapse `positions` + `edges` under `comms` into the meta-graph: meta-node
164/// centroids + aggregated crossing edges (intra-community edges vanish into the node).
165#[must_use]
166pub fn meta_graph(positions: &[[f32; 2]], edges: &[(usize, usize)], comms: &Communities) -> MetaGraph {
167    let mut members: Vec<Vec<usize>> = vec![Vec::new(); comms.count];
168    for (i, &c) in comms.of.iter().enumerate() {
169        members[c].push(i);
170    }
171    let metas = members
172        .into_iter()
173        .enumerate()
174        .map(|(c, mem)| {
175            let (mut sx, mut sy) = (0.0f32, 0.0f32);
176            for &i in &mem {
177                sx += positions[i][0];
178                sy += positions[i][1];
179            }
180            let k = mem.len().max(1) as f32;
181            MetaNode { community: c, members: mem.clone(), pos: [sx / k, sy / k], weight: mem.len() as f32 }
182        })
183        .collect();
184    let mut agg: BTreeMap<(usize, usize), f32> = BTreeMap::new();
185    for &(a, b) in edges {
186        if a >= comms.of.len() || b >= comms.of.len() {
187            continue;
188        }
189        let (ca, cb) = (comms.of[a], comms.of[b]);
190        if ca != cb {
191            *agg.entry((ca.min(cb), ca.max(cb))).or_insert(0.0) += 1.0;
192        }
193    }
194    let edges = agg.into_iter().map(|((a, b), w)| (a, b, w)).collect();
195    MetaGraph { metas, edges }
196}
197
198/// The **fracture parameter** `t ∈ [0,1]` for the current `zoom`: `0` = fully
199/// collapsed (show meta-nodes), `1` = fully fractured (show members at their real
200/// positions). Below `collapse_below` it's collapsed; above `collapse_below +
201/// span` it's open; between, the **injected** `ease` drives the bloom (pass
202/// `facett_core::effects::easing::ease_in_out_cubic` / `elastic`). Deterministic.
203#[must_use]
204pub fn fracture_t(zoom: f32, collapse_below: f32, span: f32, ease: impl Fn(f32) -> f32) -> f32 {
205    let span = span.max(1e-4);
206    let raw = ((zoom - collapse_below) / span).clamp(0.0, 1.0);
207    ease(raw).clamp(0.0, 1.0)
208}
209
210/// Per-node **rendered** positions at fracture `t`: each node lerps from its meta-node
211/// centroid (`t=0`, collapsed) to its real position (`t=1`, fractured). The host paints
212/// nodes with opacity ∝ `t` (and meta-nodes ∝ `1−t`) for the bloom-open.
213#[must_use]
214pub fn fractured_positions(positions: &[[f32; 2]], comms: &Communities, meta: &MetaGraph, t: f32) -> Vec<[f32; 2]> {
215    let t = t.clamp(0.0, 1.0);
216    positions
217        .iter()
218        .enumerate()
219        .map(|(i, p)| {
220            let c = comms.of[i];
221            let m = meta.metas[c].pos;
222            [m[0] + (p[0] - m[0]) * t, m[1] + (p[1] - m[1]) * t]
223        })
224        .collect()
225}
226
227/// How many nodes are *visible* at fracture `t`: the collapsed meta-node count when
228/// `t == 0`, the full node count when fully fractured — the semantic-zoom LOD DATA.
229#[must_use]
230pub fn visible_count(comms: &Communities, t: f32) -> usize {
231    if t <= 0.0 { comms.count } else { comms.of.len() }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    /// Two triangles (0-1-2, 3-4-5) joined by a single bridge edge 2-3 — two obvious
239    /// communities.
240    fn two_triangles() -> (usize, Vec<(usize, usize)>) {
241        (6, vec![(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)])
242    }
243
244    #[test]
245    fn louvain_finds_the_two_clusters() {
246        let (n, edges) = two_triangles();
247        let c = louvain(n, &edges);
248        assert_eq!(c.count, 2, "two communities detected (modularity {})", c.modularity);
249        // 0,1,2 share a community; 3,4,5 share the other; the two differ.
250        assert_eq!(c.of[0], c.of[1]);
251        assert_eq!(c.of[1], c.of[2]);
252        assert_eq!(c.of[3], c.of[4]);
253        assert_eq!(c.of[4], c.of[5]);
254        assert_ne!(c.of[0], c.of[3], "the bridge didn't merge the clusters");
255        assert!(c.modularity > 0.3, "a real community structure (Q={})", c.modularity);
256    }
257
258    #[test]
259    fn louvain_is_deterministic() {
260        let (n, edges) = two_triangles();
261        assert_eq!(louvain(n, &edges), louvain(n, &edges));
262    }
263
264    #[test]
265    fn edgeless_graph_is_all_singletons() {
266        let c = louvain(4, &[]);
267        assert_eq!(c.count, 4);
268        assert_eq!(c.modularity, 0.0);
269    }
270
271    #[test]
272    fn meta_graph_collapses_and_aggregates_the_bridge() {
273        let (n, edges) = two_triangles();
274        let c = louvain(n, &edges);
275        // positions: left triangle near (0,*), right near (10,*).
276        let positions = vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0], [10.0, 0.0], [10.5, 1.0], [11.0, 0.0]];
277        let mg = meta_graph(&positions, &edges, &c);
278        assert_eq!(mg.metas.len(), 2, "one meta-node per community");
279        // meta-node count strictly fewer than the 6 real nodes (the zoom-out win).
280        assert!(mg.metas.len() < n, "consolidation: {} meta-nodes < {n} nodes", mg.metas.len());
281        // only the single bridge edge survives as an inter-community meta-edge.
282        assert_eq!(mg.edges.len(), 1);
283        assert_eq!(mg.edges[0].2, 1.0, "one crossing edge aggregated");
284        // centroids land in their cluster's region.
285        let left = &mg.metas[c.of[0]];
286        assert!(left.pos[0] < 5.0 && (left.weight - 3.0).abs() < 1e-6, "3-member left cluster centroid");
287    }
288
289    #[test]
290    fn fracture_eases_from_collapsed_to_open_with_injected_clock() {
291        use std::f32::consts::PI;
292        // An injected ease (smoothstep-ish) — same dependency-injection seam helix uses.
293        let ease = |t: f32| 0.5 - 0.5 * (t * PI).cos();
294        // Far out → collapsed.
295        assert_eq!(fracture_t(0.2, 0.5, 1.0, ease), 0.0, "below threshold: fully collapsed");
296        // Far in → fully open.
297        assert!((fracture_t(2.0, 0.5, 1.0, ease) - 1.0).abs() < 1e-5, "well past: fully fractured");
298        // Mid-zoom: strictly between (the bloom is mid-animation) and monotone.
299        let mid = fracture_t(1.0, 0.5, 1.0, ease);
300        assert!(mid > 0.0 && mid < 1.0, "mid-zoom is mid-fracture ({mid})");
301        let a = fracture_t(0.7, 0.5, 1.0, ease);
302        let b = fracture_t(1.2, 0.5, 1.0, ease);
303        assert!(b > a, "zooming in advances the fracture ({a} → {b})");
304    }
305
306    #[test]
307    fn fractured_positions_and_visible_count_track_t() {
308        let (n, edges) = two_triangles();
309        let c = louvain(n, &edges);
310        let positions = vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0], [10.0, 0.0], [10.5, 1.0], [11.0, 0.0]];
311        let mg = meta_graph(&positions, &edges, &c);
312
313        // Collapsed: every member sits on its meta-centroid; visible == meta count.
314        let collapsed = fractured_positions(&positions, &c, &mg, 0.0);
315        for i in 0..n {
316            assert_eq!(collapsed[i], mg.metas[c.of[i]].pos, "node {i} collapsed onto its meta-node");
317        }
318        assert_eq!(visible_count(&c, 0.0), 2, "low zoom shows 2 meta-nodes, not 6 nodes");
319
320        // Fully fractured: members back at their real positions; visible == node count.
321        let open = fractured_positions(&positions, &c, &mg, 1.0);
322        for i in 0..n {
323            assert!((open[i][0] - positions[i][0]).abs() < 1e-5 && (open[i][1] - positions[i][1]).abs() < 1e-5);
324        }
325        assert_eq!(visible_count(&c, 1.0), 6);
326
327        // Half-fractured: a member is strictly between centroid and real position (DATA).
328        let half = fractured_positions(&positions, &c, &mg, 0.5);
329        let i = 3usize;
330        let m = mg.metas[c.of[i]].pos;
331        assert!((half[i][0] - (m[0] + (positions[i][0] - m[0]) * 0.5)).abs() < 1e-5, "lerps halfway open");
332    }
333}