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(¤t);
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}