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/// 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(&current);
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}