Skip to main content

rete_core/
pyramid.rs

1//! Community detection and quotient-graph coarsening — the engine behind the
2//! size-targeted pyramid (SPEC.md §7).
3//!
4//! This module is deliberately graph-only: it works on an abstract weighted
5//! undirected graph of node IDs `0..n` and knows nothing about RDF terms or
6//! tiles. The pyramid builder (a later layer) projects the triples onto this
7//! graph, runs [`louvain_one_level`] repeatedly — coarsening via
8//! [`Graph::quotient`] between rounds — until a level's tiles fit the byte
9//! budget, then materializes per-community triple tiles.
10
11use std::collections::HashMap;
12
13use crate::dictionary::Dictionary;
14
15/// A weighted undirected graph over nodes `0..n`. Parallel edges are summed;
16/// self-loops are kept (they affect modularity but not community moves).
17#[derive(Debug, Clone)]
18pub struct Graph {
19    n: usize,
20    /// `adj[i]` = list of `(neighbor, weight)`.
21    adj: Vec<Vec<(usize, f64)>>,
22    /// Weighted degree per node (self-loops counted twice).
23    degree: Vec<f64>,
24    /// Total edge weight `m` (each undirected edge contributes its weight once).
25    m: f64,
26}
27
28impl Graph {
29    /// Build from undirected weighted edges. `(u, v, w)` and `(v, u, w)` are
30    /// equivalent; parallel edges accumulate.
31    pub fn from_edges(n: usize, edges: &[(usize, usize, f64)]) -> Self {
32        let mut adj_w: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n];
33        let mut degree = vec![0.0; n];
34        let mut m = 0.0;
35        for &(u, v, w) in edges {
36            assert!(u < n && v < n, "edge endpoint out of range");
37            *adj_w[u].entry(v).or_insert(0.0) += w;
38            degree[u] += w;
39            m += w;
40            if u != v {
41                *adj_w[v].entry(u).or_insert(0.0) += w;
42                degree[v] += w;
43            } else {
44                // self-loop adds twice to degree.
45                degree[u] += w;
46            }
47        }
48        // Sort each node's neighbour list by index: a canonical adjacency order
49        // so every downstream traversal (gain sums, modularity, quotient) is
50        // order-stable — floating-point addition is not associative, so a
51        // randomised neighbour order would otherwise perturb sums in the low
52        // bits and make the whole pyramid (and the file's content hash)
53        // non-reproducible.
54        let adj = adj_w
55            .into_iter()
56            .map(|h| {
57                let mut nbrs: Vec<(usize, f64)> = h.into_iter().collect();
58                nbrs.sort_unstable_by_key(|&(j, _)| j);
59                nbrs
60            })
61            .collect();
62        Graph { n, adj, degree, m }
63    }
64
65    pub fn node_count(&self) -> usize {
66        self.n
67    }
68
69    pub fn total_weight(&self) -> f64 {
70        self.m
71    }
72
73    /// Newman modularity of a partition (community id per node).
74    pub fn modularity(&self, comm: &[usize]) -> f64 {
75        if self.m == 0.0 {
76            return 0.0;
77        }
78        let two_m = 2.0 * self.m;
79        let mut q = 0.0;
80        // sum over edges of [A_ij - k_i k_j / 2m] within same community.
81        for (i, neighbors) in self.adj.iter().enumerate() {
82            for &(j, w) in neighbors {
83                if comm[i] == comm[j] {
84                    q += w; // A_ij summed over ordered pairs (both directions present)
85                }
86            }
87        }
88        // subtract degree term, summed per community.
89        let mut sigma_tot: HashMap<usize, f64> = HashMap::new();
90        for (i, &c) in comm.iter().enumerate() {
91            *sigma_tot.entry(c).or_insert(0.0) += self.degree[i];
92        }
93        let deg_term: f64 = sigma_tot.values().map(|&s| s * s).sum();
94        (q - deg_term / two_m) / two_m
95    }
96
97    /// Coarsen the graph: each community becomes one node. Returns the quotient
98    /// graph and the dense community count. `comm` must use dense IDs `0..k`.
99    pub fn quotient(&self, comm: &[usize], k: usize) -> Graph {
100        let mut edges: HashMap<(usize, usize), f64> = HashMap::new();
101        for (i, neighbors) in self.adj.iter().enumerate() {
102            for &(j, w) in neighbors {
103                if i <= j {
104                    let (ci, cj) = (comm[i], comm[j]);
105                    let key = (ci.min(cj), ci.max(cj));
106                    *edges.entry(key).or_insert(0.0) += w;
107                }
108            }
109        }
110        // Canonical edge order → deterministic accumulation in `from_edges`.
111        let mut edge_vec: Vec<(usize, usize, f64)> =
112            edges.into_iter().map(|((a, b), w)| (a, b, w)).collect();
113        edge_vec.sort_unstable_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
114        Graph::from_edges(k, &edge_vec)
115    }
116}
117
118/// A community partition with dense IDs `0..count`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Partition {
121    /// Community ID per node.
122    pub comm: Vec<usize>,
123    /// Number of distinct communities.
124    pub count: usize,
125}
126
127/// Which algorithm forms the pyramid's communities. The format and every
128/// downstream consumer key on opaque community IDs, so the choice only changes
129/// *how the partition is computed* — all modes must be deterministic to keep the
130/// file content-hash reproducible (see `docs/BENCHMARK.md`).
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum PyramidAlgo {
133    /// Topological communities by Louvain modularity (the default, byte-identical
134    /// across runs). "What clusters densely?"
135    #[default]
136    Louvain,
137    /// RDF-native partition: one community per `rdf:type` class (the type
138    /// predicate auto-detected, or forced via `--type-predicate`), untyped /
139    /// object-only nodes in one «untyped» bucket. Communities are self-naming
140    /// (a community *is* a class), the summary graph becomes the class→class
141    /// relation graph, and the subClassOf rollups supply the coarser levels.
142    /// Falls back to [`PyramidAlgo::Louvain`] when the graph has no usable typing.
143    Types,
144}
145
146impl PyramidAlgo {
147    /// Parse the CLI spelling (`louvain` / `types`); `None` for anything else.
148    pub fn from_cli(s: &str) -> Option<Self> {
149        match s {
150            "louvain" => Some(Self::Louvain),
151            "types" => Some(Self::Types),
152            _ => None,
153        }
154    }
155}
156
157/// One level of Louvain local-moving modularity optimization.
158///
159/// Nodes start in their own community; each is greedily moved to the neighboring
160/// community giving the best modularity gain until no move improves. IDs in the
161/// result are renumbered densely.
162pub fn louvain_one_level(g: &Graph) -> Partition {
163    let n = g.n;
164    if g.m == 0.0 {
165        return Partition {
166            comm: (0..n).collect(),
167            count: n,
168        };
169    }
170    let two_m = 2.0 * g.m;
171    let mut comm: Vec<usize> = (0..n).collect();
172    let mut sigma_tot: Vec<f64> = g.degree.clone(); // each node alone
173
174    // Reusable scratch (allocated once, not per node): `k_to[c]` is the edge weight
175    // from the current node `i` into community `c`, valid only for the communities
176    // recorded in `touched`. A dense array + touched-list replaces the per-node
177    // `HashMap` the textbook formulation allocates — same accumulation order (the
178    // adjacency is index-sorted, so the float sums are bit-identical) and the same
179    // sorted candidate scan, so the partition is byte-for-byte unchanged. Edge
180    // weights are strictly positive, so `k_to[c] == 0.0` reliably means "untouched".
181    let mut k_to = vec![0.0f64; n];
182    let mut touched: Vec<usize> = Vec::new();
183
184    let mut improved = true;
185    while improved {
186        improved = false;
187        for (i, neighbors) in g.adj.iter().enumerate() {
188            let ci = comm[i];
189            let ki = g.degree[i];
190
191            // Tentatively remove i from its community.
192            sigma_tot[ci] -= ki;
193
194            // Edge weight from i into each neighboring community (into the dense
195            // scratch, recording first-touch communities for O(touched) reset).
196            for &(j, w) in neighbors {
197                if j != i {
198                    let cj = comm[j];
199                    if k_to[cj] == 0.0 {
200                        touched.push(cj);
201                    }
202                    k_to[cj] += w;
203                }
204            }
205
206            // Gain of placing i into community c: k_{i,c} - sigma_tot[c]*ki/2m.
207            let gain = |c: usize| -> f64 { k_to[c] - sigma_tot[c] * ki / two_m };
208            // Scan candidate communities in a deterministic (sorted) order so an
209            // equal-gain tie always resolves to the same community across runs —
210            // the second half of making the pyramid reproducible. Strict `>`
211            // keeps the first (smallest-id) community on ties.
212            let mut best_c = ci;
213            let mut best_gain = gain(ci);
214            touched.sort_unstable();
215            for &c in &touched {
216                let gc = gain(c);
217                if gc > best_gain {
218                    best_gain = gc;
219                    best_c = c;
220                }
221            }
222
223            sigma_tot[best_c] += ki;
224            comm[i] = best_c;
225            if best_c != ci {
226                improved = true;
227            }
228
229            // Reset only the touched entries for the next node.
230            for &c in &touched {
231                k_to[c] = 0.0;
232            }
233            touched.clear();
234        }
235    }
236
237    // Renumber densely.
238    let mut remap: HashMap<usize, usize> = HashMap::new();
239    for c in &mut comm {
240        let next = remap.len();
241        *c = *remap.entry(*c).or_insert(next);
242    }
243    Partition {
244        count: remap.len(),
245        comm,
246    }
247}
248
249/// Project RDF integer triples onto the undirected node graph the pyramid
250/// clusters: one node per distinct term (via the dictionary's unified node
251/// space), one unit-weight edge per `(subject, object)` pair (predicates are
252/// ignored for clustering; parallel edges accumulate weight).
253pub fn project_graph(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Graph {
254    let n = dict.node_count() as usize;
255    let edges: Vec<(usize, usize, f64)> = triples
256        .iter()
257        .map(|&(s, _, o)| {
258            (
259                dict.subject_node(s) as usize,
260                dict.object_node(o) as usize,
261                1.0,
262            )
263        })
264        .collect();
265    Graph::from_edges(n, &edges)
266}
267
268/// A hierarchy of community partitions (a dendrogram). `levels[0]` partitions the
269/// base nodes; `levels[k]` partitions level `k-1`'s communities. Round 0 is the
270/// finest grouping; the last round is the coarsest (pyramid level 0).
271#[derive(Debug, Clone)]
272pub struct Dendrogram {
273    pub levels: Vec<Partition>,
274}
275
276impl Dendrogram {
277    /// Number of coarsening rounds (0 if the graph had no community structure).
278    pub fn rounds(&self) -> usize {
279        self.levels.len()
280    }
281
282    /// Distinct community count at `round`.
283    pub fn community_count(&self, round: usize) -> usize {
284        self.levels[round].count
285    }
286
287    /// The community a base `node` belongs to at the given `round`, composing
288    /// the partitions `levels[0..=round]`.
289    pub fn base_community(&self, node: usize, round: usize) -> usize {
290        let mut c = node;
291        for level in &self.levels[..=round] {
292            c = level.comm[c];
293        }
294        c
295    }
296}
297
298/// Build the full dendrogram by repeated Louvain + coarsening, stopping when a
299/// round no longer compresses the graph (or only one node remains).
300pub fn build_dendrogram(g: &Graph) -> Dendrogram {
301    let mut levels = Vec::new();
302    let mut current = g.clone();
303    loop {
304        let p = louvain_one_level(&current);
305        if p.count >= current.node_count() {
306            break; // no compression — stop
307        }
308        let next = current.quotient(&p.comm, p.count);
309        levels.push(p);
310        if next.node_count() <= 1 {
311            break;
312        }
313        current = next;
314    }
315    Dendrogram { levels }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::dictionary::DictionaryBuilder;
322
323    /// Two triangles {0,1,2} and {3,4,5} joined by a single 3–? bridge.
324    fn barbell() -> Graph {
325        let edges = [
326            (0, 1, 1.0),
327            (1, 2, 1.0),
328            (0, 2, 1.0),
329            (3, 4, 1.0),
330            (4, 5, 1.0),
331            (3, 5, 1.0),
332            (2, 3, 1.0), // bridge
333        ];
334        Graph::from_edges(6, &edges)
335    }
336
337    #[test]
338    fn two_communities() {
339        let g = barbell();
340        let p = louvain_one_level(&g);
341        assert_eq!(p.count, 2, "barbell should split into two communities");
342        // Nodes 0,1,2 together; 3,4,5 together.
343        assert_eq!(p.comm[0], p.comm[1]);
344        assert_eq!(p.comm[1], p.comm[2]);
345        assert_eq!(p.comm[3], p.comm[4]);
346        assert_eq!(p.comm[4], p.comm[5]);
347        assert_ne!(p.comm[2], p.comm[3]);
348        // Modularity of the found partition beats the all-in-one partition.
349        assert!(g.modularity(&p.comm) > g.modularity(&[0; 6]));
350    }
351
352    #[test]
353    fn dendrogram_is_deterministic_across_runs() {
354        // A tie-prone graph: six 6-cliques in a ring, joined by single bridges,
355        // so many local moves have equal-gain candidates. Two builds in the same
356        // process use different HashMap seeds — they must still agree exactly, or
357        // the pyramid (and the file's content hash) is not reproducible.
358        let mut edges = Vec::new();
359        let clusters = 6;
360        let size = 6;
361        for c in 0..clusters {
362            let base = c * size;
363            for i in 0..size {
364                for j in (i + 1)..size {
365                    edges.push((base + i, base + j, 1.0));
366                }
367            }
368            // bridge to the next cluster (ring).
369            let next = ((c + 1) % clusters) * size;
370            edges.push((base, next, 1.0));
371        }
372        let g = Graph::from_edges(clusters * size, &edges);
373        let a = build_dendrogram(&g);
374        let b = build_dendrogram(&g);
375        assert_eq!(a.levels, b.levels, "dendrogram must be reproducible");
376        assert!(!a.levels.is_empty(), "the ring of cliques should compress");
377    }
378
379    #[test]
380    fn quotient_collapses_communities() {
381        let g = barbell();
382        let p = louvain_one_level(&g);
383        let q = g.quotient(&p.comm, p.count);
384        assert_eq!(q.node_count(), 2);
385        // Total weight is preserved by coarsening.
386        assert!((q.total_weight() - g.total_weight()).abs() < 1e-9);
387    }
388
389    #[test]
390    fn empty_graph_is_singletons() {
391        let g = Graph::from_edges(3, &[]);
392        let p = louvain_one_level(&g);
393        assert_eq!(p.count, 3);
394    }
395
396    #[test]
397    fn dendrogram_groups_barbell() {
398        let g = barbell();
399        let d = build_dendrogram(&g);
400        assert!(d.rounds() >= 1);
401        // At the finest round, the two triangles are distinct communities.
402        let c0 = d.base_community(0, 0);
403        assert_eq!(c0, d.base_community(1, 0));
404        assert_eq!(c0, d.base_community(2, 0));
405        let c3 = d.base_community(3, 0);
406        assert_eq!(c3, d.base_community(4, 0));
407        assert_ne!(c0, c3);
408    }
409
410    /// Two fully-connected triples-clusters joined by one bridge edge, expressed
411    /// as RDF `knows` triples, then projected and clustered.
412    #[test]
413    fn project_and_cluster_rdf() {
414        let edges = [
415            ("A", "B"),
416            ("B", "C"),
417            ("A", "C"),
418            ("D", "E"),
419            ("E", "F"),
420            ("D", "F"),
421            ("C", "D"), // bridge
422        ];
423        let mut db = DictionaryBuilder::new();
424        for (s, o) in edges {
425            db.observe(s, "knows", o);
426        }
427        let dict = db.build();
428        let triples: Vec<_> = edges
429            .iter()
430            .map(|(s, o)| dict.encode(s, "knows", o).unwrap())
431            .collect();
432
433        let g = project_graph(&dict, &triples);
434        assert_eq!(g.node_count(), 6); // A..F all shared nodes
435        let d = build_dendrogram(&g);
436
437        // A is subject-only, F is object-only — resolve a node via either role.
438        let node = |t: &str| {
439            dict.subject_id(t)
440                .map(|id| dict.subject_node(id))
441                .or_else(|| dict.object_id(t).map(|id| dict.object_node(id)))
442                .unwrap() as usize
443        };
444        let comm = |t: &str| d.base_community(node(t), 0);
445        // A, B, C cluster together; D, E, F cluster together; clusters differ.
446        assert_eq!(comm("A"), comm("B"));
447        assert_eq!(comm("B"), comm("C"));
448        assert_eq!(comm("D"), comm("E"));
449        assert_eq!(comm("E"), comm("F"));
450        assert_ne!(comm("C"), comm("D"));
451    }
452}