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/// Most local-moving passes one Louvain level may make before it stops and
158/// returns the partition it has.
159///
160/// A **backstop**, not the termination argument. With the exact move test of
161/// [`louvain_one_level`] every move raises the modularity strictly, so the
162/// passes end on their own. Measured: at most 24 passes on any level of 500
163/// million random graphs, and at most 186 on 21 real datasets of up to 11.7
164/// million triples (proteinbase's base level; the next highest was 77). The
165/// cap is about ten times that worst case (`docs/SPEC.md` §7.1). It exists so
166/// that no future numeric defect, or a caller of the public
167/// [`Graph::from_edges`] with weights the exact path does not cover, can turn
168/// a build into an infinite loop again, as rete 0.3.3 and earlier could (see
169/// [`louvain_one_level`]). Reaching it is not an error: the level ends with
170/// the partition it has, which is valid, merely one a further pass might
171/// still have improved.
172pub const MAX_LOUVAIN_PASSES: usize = 2000;
173
174/// What one Louvain level did (for tests and the termination harness).
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub struct LevelStats {
177 /// Local-moving passes made (each a sweep over every node).
178 pub passes: usize,
179 /// True if the level stopped at [`MAX_LOUVAIN_PASSES`] with nodes still
180 /// moving, rather than converging.
181 pub capped: bool,
182}
183
184/// How a level compares two candidate communities' gains.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum GainTest {
187 /// Integer weights and `(2m)² ≤ 2^53`: the cross-multiplied gains are
188 /// integers an `f64` holds exactly.
189 ExactF64,
190 /// Integer weights, larger graph: the same integers in `i128`.
191 ExactI128,
192 /// Non-integer weights (reachable only through the public
193 /// [`Graph::from_edges`]; nothing in rete builds such a graph): the
194 /// floating-point gain, where a move counts only when it wins by more than
195 /// a bound on its rounding error.
196 Tolerant,
197}
198
199/// `2^53`: every integer up to it is exactly representable in an `f64`.
200const F64_EXACT_INT: f64 = 9_007_199_254_740_992.0;
201
202impl Graph {
203 fn gain_test(&self) -> GainTest {
204 let two_m = 2.0 * self.m;
205 let integral = |x: f64| x.is_finite() && x.fract() == 0.0;
206 // Every edge weight integral, and every sum the level forms (degrees,
207 // community totals, k_{i,c}, all ≤ 2m) exactly representable.
208 let exact_sums = integral(self.m)
209 && two_m <= F64_EXACT_INT
210 && self
211 .adj
212 .iter()
213 .all(|nbrs| nbrs.iter().all(|&(_, w)| integral(w)));
214 if !exact_sums {
215 GainTest::Tolerant
216 } else if two_m * two_m <= F64_EXACT_INT {
217 GainTest::ExactF64
218 } else {
219 GainTest::ExactI128
220 }
221 }
222}
223
224/// One level of Louvain local-moving modularity optimization.
225///
226/// Nodes start in their own community; each is greedily moved to the neighboring
227/// community giving the best modularity gain until no move improves. IDs in the
228/// result are renumbered densely.
229///
230/// # The move test is exact
231///
232/// Moving node `i` (degree `k_i`, with `k_{i,c}` of its edge weight going into
233/// community `c`, whose other members' degrees total `Σ_c`) into `c` gains
234/// `k_{i,c} − Σ_c·k_i / 2m`, up to a positive factor common to every `c`.
235/// Through 0.3.3 that was computed in floating point, and the division by `2m`
236/// is inexact whenever `2m` is not a power of two. Two candidates with *equal*
237/// gains could then round one ulp apart, the strict `>` moved a node on a gain
238/// of zero, a second node did the same the other way, and the passes, which
239/// had no limit, swapped the pair forever. A graph of 15 triples is enough
240/// (`tests/louvain_termination.rs`).
241///
242/// The test is now made on `k_{i,c}·2m − Σ_c·k_i`, the gain times `2m`, with no
243/// division. Every weight rete builds is an integer count (one per triple,
244/// summed by [`Graph::quotient`]), so these are integers. They are exact in
245/// `f64` while `(2m)² ≤ 2^53`, because every product is at most `(2m)²`
246/// (`k_{i,c} ≤ k_i ≤ 2m` and `Σ_c ≤ 2m`): up to 47,453,132 projected edges.
247/// Beyond that they are computed in `i128`, exact while `2m ≤ 2^53`, the point
248/// where the `f64` sums themselves would stop being exact. So a node moves only
249/// on a strictly positive true gain, each move raises the modularity, and the
250/// passes must end. For non-integer weights (only via the public
251/// [`Graph::from_edges`]) a move must win by more than a bound on the rounding
252/// error, which keeps every accepted move a true improvement as well.
253/// [`MAX_LOUVAIN_PASSES`] backs all of this up.
254///
255/// Ties keep the old rule: candidates are scanned in sorted order and the
256/// first best one wins (the node's own community, then the smallest id). The
257/// partition is therefore reproducible across runs and platforms; native and
258/// wasm compute the same integers.
259pub fn louvain_one_level(g: &Graph) -> Partition {
260 louvain_one_level_stats(g).0
261}
262
263/// [`louvain_one_level`], also returning how many passes it made and whether
264/// it stopped at [`MAX_LOUVAIN_PASSES`].
265pub fn louvain_one_level_stats(g: &Graph) -> (Partition, LevelStats) {
266 let n = g.n;
267 if g.m == 0.0 {
268 return (
269 Partition {
270 comm: (0..n).collect(),
271 count: n,
272 },
273 LevelStats::default(),
274 );
275 }
276 let two_m = 2.0 * g.m;
277 let test = g.gain_test();
278 let mut comm: Vec<usize> = (0..n).collect();
279 let mut sigma_tot: Vec<f64> = g.degree.clone(); // each node alone
280
281 // Reusable scratch (allocated once, not per node): `k_to[c]` is the edge weight
282 // from the current node `i` into community `c`, valid only for the communities
283 // recorded in `touched`. A dense array + touched-list replaces the per-node
284 // `HashMap` the textbook formulation allocates — same accumulation order (the
285 // adjacency is index-sorted, so the float sums are bit-identical) and the same
286 // sorted candidate scan. Edge weights are strictly positive, so
287 // `k_to[c] == 0.0` reliably means "untouched".
288 let mut k_to = vec![0.0f64; n];
289 let mut touched: Vec<usize> = Vec::new();
290
291 let mut stats = LevelStats::default();
292 let mut improved = true;
293 while improved {
294 if stats.passes == MAX_LOUVAIN_PASSES {
295 stats.capped = true;
296 break;
297 }
298 stats.passes += 1;
299 improved = false;
300 for (i, neighbors) in g.adj.iter().enumerate() {
301 let ci = comm[i];
302 let ki = g.degree[i];
303
304 // Tentatively remove i from its community.
305 sigma_tot[ci] -= ki;
306
307 // Edge weight from i into each neighboring community (into the dense
308 // scratch, recording first-touch communities for O(touched) reset).
309 for &(j, w) in neighbors {
310 if j != i {
311 let cj = comm[j];
312 if k_to[cj] == 0.0 {
313 touched.push(cj);
314 }
315 k_to[cj] += w;
316 }
317 }
318
319 // Scan candidate communities in a deterministic (sorted) order so an
320 // equal-gain tie always resolves to the same community across runs —
321 // the second half of making the pyramid reproducible. Strict `>`
322 // keeps the first candidate (i's own community, then the smallest
323 // id) on ties. The gains compared are exact; see the doc comment.
324 touched.sort_unstable();
325 let mut best_c = ci;
326 match test {
327 GainTest::ExactF64 => {
328 // Integers ≤ (2m)² ≤ 2^53: exact, and so is their difference.
329 let gain = |c: usize| k_to[c] * two_m - sigma_tot[c] * ki;
330 let mut best = gain(ci);
331 for &c in &touched {
332 let gc = gain(c);
333 if gc > best {
334 best = gc;
335 best_c = c;
336 }
337 }
338 }
339 GainTest::ExactI128 => {
340 // Integers ≤ 2^53, so `as` is exact; products < 2^106.
341 let (tm, k) = (two_m as i128, ki as i128);
342 let gain = |c: usize| (k_to[c] as i128) * tm - (sigma_tot[c] as i128) * k;
343 let mut best = gain(ci);
344 for &c in &touched {
345 let gc = gain(c);
346 if gc > best {
347 best = gc;
348 best_c = c;
349 }
350 }
351 }
352 GainTest::Tolerant => {
353 // Both terms of a gain are at most `ki` in magnitude
354 // (k_{i,c} ≤ k_i, Σ_c ≤ 2m) and come from at most deg+3
355 // rounded operations, so each gain is off by less than
356 // (deg+4)·ε·k_i, and a difference of two by twice that. A
357 // move must win by more than this margin, so every move
358 // accepted is a true gain.
359 let tol = 2.0 * (neighbors.len() as f64 + 4.0) * f64::EPSILON * ki;
360 let gain = |c: usize| k_to[c] - sigma_tot[c] * ki / two_m;
361 let mut best = gain(ci);
362 for &c in &touched {
363 let gc = gain(c);
364 if gc > best + tol {
365 best = gc;
366 best_c = c;
367 }
368 }
369 }
370 }
371
372 sigma_tot[best_c] += ki;
373 comm[i] = best_c;
374 if best_c != ci {
375 improved = true;
376 }
377
378 // Reset only the touched entries for the next node.
379 for &c in &touched {
380 k_to[c] = 0.0;
381 }
382 touched.clear();
383 }
384 }
385
386 // Renumber densely.
387 let mut remap: HashMap<usize, usize> = HashMap::new();
388 for c in &mut comm {
389 let next = remap.len();
390 *c = *remap.entry(*c).or_insert(next);
391 }
392 (
393 Partition {
394 count: remap.len(),
395 comm,
396 },
397 stats,
398 )
399}
400
401/// Project RDF integer triples onto the undirected node graph the pyramid
402/// clusters: one node per distinct term (via the dictionary's unified node
403/// space), one unit-weight edge per `(subject, object)` pair (predicates are
404/// ignored for clustering; parallel edges accumulate weight).
405pub fn project_graph(dict: &Dictionary, triples: &[(u32, u32, u32)]) -> Graph {
406 let n = dict.node_count() as usize;
407 let edges: Vec<(usize, usize, f64)> = triples
408 .iter()
409 .map(|&(s, _, o)| {
410 (
411 dict.subject_node(s) as usize,
412 dict.object_node(o) as usize,
413 1.0,
414 )
415 })
416 .collect();
417 Graph::from_edges(n, &edges)
418}
419
420/// A hierarchy of community partitions (a dendrogram). `levels[0]` partitions the
421/// base nodes; `levels[k]` partitions level `k-1`'s communities. Round 0 is the
422/// finest grouping; the last round is the coarsest (pyramid level 0).
423#[derive(Debug, Clone)]
424pub struct Dendrogram {
425 pub levels: Vec<Partition>,
426}
427
428impl Dendrogram {
429 /// Number of coarsening rounds (0 if the graph had no community structure).
430 pub fn rounds(&self) -> usize {
431 self.levels.len()
432 }
433
434 /// Distinct community count at `round`.
435 pub fn community_count(&self, round: usize) -> usize {
436 self.levels[round].count
437 }
438
439 /// The community a base `node` belongs to at the given `round`, composing
440 /// the partitions `levels[0..=round]`.
441 pub fn base_community(&self, node: usize, round: usize) -> usize {
442 let mut c = node;
443 for level in &self.levels[..=round] {
444 c = level.comm[c];
445 }
446 c
447 }
448}
449
450/// Build the full dendrogram by repeated Louvain + coarsening, stopping when a
451/// round no longer compresses the graph (or only one node remains).
452pub fn build_dendrogram(g: &Graph) -> Dendrogram {
453 let mut levels = Vec::new();
454 let mut current = g.clone();
455 loop {
456 let p = louvain_one_level(¤t);
457 if p.count >= current.node_count() {
458 break; // no compression — stop
459 }
460 let next = current.quotient(&p.comm, p.count);
461 levels.push(p);
462 if next.node_count() <= 1 {
463 break;
464 }
465 current = next;
466 }
467 Dendrogram { levels }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::dictionary::DictionaryBuilder;
474
475 /// Two triangles {0,1,2} and {3,4,5} joined by a single 3–? bridge.
476 fn barbell() -> Graph {
477 let edges = [
478 (0, 1, 1.0),
479 (1, 2, 1.0),
480 (0, 2, 1.0),
481 (3, 4, 1.0),
482 (4, 5, 1.0),
483 (3, 5, 1.0),
484 (2, 3, 1.0), // bridge
485 ];
486 Graph::from_edges(6, &edges)
487 }
488
489 #[test]
490 fn two_communities() {
491 let g = barbell();
492 let p = louvain_one_level(&g);
493 assert_eq!(p.count, 2, "barbell should split into two communities");
494 // Nodes 0,1,2 together; 3,4,5 together.
495 assert_eq!(p.comm[0], p.comm[1]);
496 assert_eq!(p.comm[1], p.comm[2]);
497 assert_eq!(p.comm[3], p.comm[4]);
498 assert_eq!(p.comm[4], p.comm[5]);
499 assert_ne!(p.comm[2], p.comm[3]);
500 // Modularity of the found partition beats the all-in-one partition.
501 assert!(g.modularity(&p.comm) > g.modularity(&[0; 6]));
502 }
503
504 #[test]
505 fn dendrogram_is_deterministic_across_runs() {
506 // A tie-prone graph: six 6-cliques in a ring, joined by single bridges,
507 // so many local moves have equal-gain candidates. Two builds in the same
508 // process use different HashMap seeds — they must still agree exactly, or
509 // the pyramid (and the file's content hash) is not reproducible.
510 let mut edges = Vec::new();
511 let clusters = 6;
512 let size = 6;
513 for c in 0..clusters {
514 let base = c * size;
515 for i in 0..size {
516 for j in (i + 1)..size {
517 edges.push((base + i, base + j, 1.0));
518 }
519 }
520 // bridge to the next cluster (ring).
521 let next = ((c + 1) % clusters) * size;
522 edges.push((base, next, 1.0));
523 }
524 let g = Graph::from_edges(clusters * size, &edges);
525 let a = build_dendrogram(&g);
526 let b = build_dendrogram(&g);
527 assert_eq!(a.levels, b.levels, "dendrogram must be reproducible");
528 assert!(!a.levels.is_empty(), "the ring of cliques should compress");
529 }
530
531 #[test]
532 fn quotient_collapses_communities() {
533 let g = barbell();
534 let p = louvain_one_level(&g);
535 let q = g.quotient(&p.comm, p.count);
536 assert_eq!(q.node_count(), 2);
537 // Total weight is preserved by coarsening.
538 assert!((q.total_weight() - g.total_weight()).abs() < 1e-9);
539 }
540
541 #[test]
542 fn empty_graph_is_singletons() {
543 let g = Graph::from_edges(3, &[]);
544 let p = louvain_one_level(&g);
545 assert_eq!(p.count, 3);
546 }
547
548 #[test]
549 fn dendrogram_groups_barbell() {
550 let g = barbell();
551 let d = build_dendrogram(&g);
552 assert!(d.rounds() >= 1);
553 // At the finest round, the two triangles are distinct communities.
554 let c0 = d.base_community(0, 0);
555 assert_eq!(c0, d.base_community(1, 0));
556 assert_eq!(c0, d.base_community(2, 0));
557 let c3 = d.base_community(3, 0);
558 assert_eq!(c3, d.base_community(4, 0));
559 assert_ne!(c0, c3);
560 }
561
562 /// The base graph of `tests/fixtures/louvain-hangs/min-15-level0.nt`, on
563 /// which rete 0.3.3 looped forever, as (n, edges).
564 fn min_15_edges() -> (usize, Vec<(usize, usize, f64)>) {
565 let text = include_str!("../tests/fixtures/louvain-hangs/min-15-level0.nt");
566 let quads = crate::ingest::parse_statements(text, "nt").unwrap();
567 let mut db = DictionaryBuilder::new();
568 for (s, p, o, _) in &quads {
569 db.observe(s, p, o);
570 }
571 let dict = db.build();
572 let edges = quads
573 .iter()
574 .map(|(s, p, o, _)| {
575 let (s, _, o) = dict.encode(s, p, o).unwrap();
576 (
577 dict.subject_node(s) as usize,
578 dict.object_node(o) as usize,
579 1.0,
580 )
581 })
582 .collect();
583 (dict.node_count() as usize, edges)
584 }
585
586 #[test]
587 fn the_gain_test_follows_the_weights() {
588 let (n, edges) = min_15_edges();
589 assert_eq!(Graph::from_edges(n, &edges).gain_test(), GainTest::ExactF64);
590 // (2m)² > 2^53: integers too big to cross-multiply in f64.
591 let big: Vec<_> = edges.iter().map(|&(u, v, _)| (u, v, 1e7)).collect();
592 assert_eq!(Graph::from_edges(n, &big).gain_test(), GainTest::ExactI128);
593 let half: Vec<_> = edges.iter().map(|&(u, v, _)| (u, v, 0.5)).collect();
594 assert_eq!(Graph::from_edges(n, &half).gain_test(), GainTest::Tolerant);
595 }
596
597 /// Modularity is invariant under scaling every weight, so all three paths
598 /// must find the same partition of the graph that used to hang, and none
599 /// may need the pass cap.
600 #[test]
601 fn every_gain_test_converges_on_the_old_hang_to_one_partition() {
602 let (n, edges) = min_15_edges();
603 let base = louvain_one_level_stats(&Graph::from_edges(n, &edges));
604 assert!(!base.1.capped);
605 for (scale, path) in [
606 (1e7, GainTest::ExactI128),
607 (0.5, GainTest::Tolerant),
608 (0.1, GainTest::Tolerant),
609 ] {
610 let scaled: Vec<_> = edges.iter().map(|&(u, v, w)| (u, v, w * scale)).collect();
611 let g = Graph::from_edges(n, &scaled);
612 assert_eq!(g.gain_test(), path);
613 let (p, st) = louvain_one_level_stats(&g);
614 assert!(!st.capped, "scale {scale}: capped");
615 assert_eq!(p, base.0, "scale {scale}: a different partition");
616 }
617 }
618
619 #[test]
620 fn levels_report_their_passes() {
621 let (p, st) = louvain_one_level_stats(&barbell());
622 assert_eq!(p.count, 2);
623 assert!(st.passes >= 2 && st.passes < MAX_LOUVAIN_PASSES, "{st:?}");
624 assert!(!st.capped);
625 let (_, st) = louvain_one_level_stats(&Graph::from_edges(3, &[]));
626 assert_eq!(st, LevelStats::default());
627 }
628
629 /// Two fully-connected triples-clusters joined by one bridge edge, expressed
630 /// as RDF `knows` triples, then projected and clustered.
631 #[test]
632 fn project_and_cluster_rdf() {
633 let edges = [
634 ("A", "B"),
635 ("B", "C"),
636 ("A", "C"),
637 ("D", "E"),
638 ("E", "F"),
639 ("D", "F"),
640 ("C", "D"), // bridge
641 ];
642 let mut db = DictionaryBuilder::new();
643 for (s, o) in edges {
644 db.observe(s, "knows", o);
645 }
646 let dict = db.build();
647 let triples: Vec<_> = edges
648 .iter()
649 .map(|(s, o)| dict.encode(s, "knows", o).unwrap())
650 .collect();
651
652 let g = project_graph(&dict, &triples);
653 assert_eq!(g.node_count(), 6); // A..F all shared nodes
654 let d = build_dendrogram(&g);
655
656 // A is subject-only, F is object-only — resolve a node via either role.
657 let node = |t: &str| {
658 dict.subject_id(t)
659 .map(|id| dict.subject_node(id))
660 .or_else(|| dict.object_id(t).map(|id| dict.object_node(id)))
661 .unwrap() as usize
662 };
663 let comm = |t: &str| d.base_community(node(t), 0);
664 // A, B, C cluster together; D, E, F cluster together; clusters differ.
665 assert_eq!(comm("A"), comm("B"));
666 assert_eq!(comm("B"), comm("C"));
667 assert_eq!(comm("D"), comm("E"));
668 assert_eq!(comm("E"), comm("F"));
669 assert_ne!(comm("C"), comm("D"));
670 }
671}