Skip to main content

memstead_base/graph/
community.rs

1//! Louvain community detection with deterministic seeding.
2//!
3//! Implements the Louvain algorithm from scratch, matching the graphology
4//! implementation's traversal order and PRNG for bit-exact deterministic output.
5//! Output is pure in-memory — no disk persistence.
6
7use super::{BRIDGE_SAMPLE_CAP, ClusterInfo, CommunityBridge, LouvainOutput, SampleEdge};
8use crate::entity::EntityId;
9use crate::store::Store;
10use std::collections::{BTreeSet, HashMap, HashSet};
11
12// ---------------------------------------------------------------------------
13// Seeded PRNG — mulberry32 (must match JS exactly)
14// ---------------------------------------------------------------------------
15
16/// Mulberry32 PRNG matching the JS implementation.
17/// Uses i32 signed arithmetic for `| 0` coercion (overflow wrapping),
18/// but `>>>` (unsigned right shift) must use u32 casts — JS `>>> n`
19/// first converts to unsigned 32-bit, then shifts with zero-fill.
20pub fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
21    let mut s = seed as i32;
22    move || {
23        s = s.wrapping_add(0x6d2b79f5_u32 as i32);
24        // JS >>> is unsigned right shift: cast to u32, shift, cast back
25        let mut t: i32 = (s ^ ((s as u32 >> 15) as i32)).wrapping_mul(1 | s);
26        t = (t.wrapping_add((t ^ ((t as u32 >> 7) as i32)).wrapping_mul(61 | t))) ^ t;
27        (((t ^ ((t as u32 >> 14) as i32)) as u32) as f64) / 4294967296.0
28    }
29}
30
31// ---------------------------------------------------------------------------
32// Louvain index — CSR-based undirected graph for community detection
33// ---------------------------------------------------------------------------
34
35/// Compressed Sparse Row index for the Louvain algorithm.
36/// Mirrors the graphology UndirectedLouvainIndex structure.
37struct LouvainIndex {
38    c: usize, // number of active communities
39    m: f64,   // total edge weight
40    e: usize, // CSR edge count (2 * undirected edges)
41    u: usize, // unused community stack pointer
42
43    resolution: f64,
44    nodes: Vec<String>, // original node IDs (EntityId strings)
45
46    // Edge-level (CSR)
47    neighborhood: Vec<usize>, // target node indices
48    weights: Vec<f64>,        // edge weights (parallel to neighborhood)
49
50    // Node-level
51    loops: Vec<f64>,        // self-loop weights per node
52    starts: Vec<usize>,     // CSR row pointers (length = C + 1 at init)
53    belongings: Vec<usize>, // community assignment per node
54    mapping: Vec<usize>,    // final mapping from original node to community
55
56    // Community-level
57    counts: Vec<usize>,      // node count per community
58    unused: Vec<usize>,      // stack of unused community IDs
59    total_weights: Vec<f64>, // total weight per community
60}
61
62impl LouvainIndex {
63    /// Build the Louvain index from an undirected weighted graph.
64    ///
65    /// `nodes`: ordered list of node IDs
66    /// `edges`: list of (source_idx, target_idx, weight) for undirected edges
67    fn new(nodes: Vec<String>, edges: &[(usize, usize, f64)], resolution: f64) -> Self {
68        let n = nodes.len();
69
70        // Count degree (excluding self-loops) for each node to compute CSR starts
71        let mut degree = vec![0usize; n];
72        let mut self_loop_count = 0usize;
73        for &(src, tgt, _) in edges {
74            if src == tgt {
75                self_loop_count += 1;
76            } else {
77                degree[src] += 1;
78                degree[tgt] += 1;
79            }
80        }
81
82        let size = (edges.len() - self_loop_count) * 2;
83        let mut neighborhood = vec![0usize; size];
84        let mut weights = vec![0.0f64; size];
85        let mut loops = vec![0.0f64; n];
86
87        // Compute starts: cumulative degree, counting DOWN like graphology
88        let mut starts = vec![0usize; n + 1];
89        {
90            let mut cum = 0usize;
91            for i in 0..n {
92                cum += degree[i];
93                starts[i] = cum;
94            }
95            starts[n] = size;
96        }
97
98        let mut belongings = vec![0usize; n];
99        let mut counts = vec![0usize; n];
100        let mut total_weights = vec![0.0f64; n];
101
102        for i in 0..n {
103            belongings[i] = i;
104            counts[i] = 1;
105        }
106
107        let mut m = 0.0f64;
108
109        // Single sweep over edges — fill CSR arrays
110        for &(source, target, weight) in edges {
111            m += weight;
112
113            if source == target {
114                total_weights[source] += weight * 2.0;
115                loops[source] = weight * 2.0;
116            } else {
117                total_weights[source] += weight;
118                total_weights[target] += weight;
119
120                starts[source] -= 1;
121                let start_source = starts[source];
122                starts[target] -= 1;
123                let start_target = starts[target];
124
125                neighborhood[start_source] = target;
126                neighborhood[start_target] = source;
127                weights[start_source] = weight;
128                weights[start_target] = weight;
129            }
130        }
131
132        let mapping = belongings.clone();
133        let unused = vec![0usize; n];
134
135        LouvainIndex {
136            c: n,
137            m,
138            e: size,
139            u: 0,
140            resolution,
141            nodes,
142            neighborhood,
143            weights,
144            loops,
145            starts,
146            belongings,
147            mapping,
148            counts,
149            unused,
150            total_weights,
151        }
152    }
153
154    #[allow(dead_code)] // Part of the Louvain interface, used by non-fast paths
155    fn compute_node_degree(&self, i: usize) -> f64 {
156        let mut degree = 0.0;
157        let start = self.starts[i];
158        let end = self.starts[i + 1];
159        for o in start..end {
160            degree += self.weights[o];
161        }
162        degree
163    }
164
165    fn isolate(&mut self, i: usize, degree: f64) -> usize {
166        let current_community = self.belongings[i];
167
168        // Already isolated
169        if self.counts[current_community] == 1 {
170            return current_community;
171        }
172
173        self.u -= 1;
174        let new_community = self.unused[self.u];
175        let loops = self.loops[i];
176
177        self.total_weights[current_community] -= degree + loops;
178        self.total_weights[new_community] += degree + loops;
179        self.belongings[i] = new_community;
180        self.counts[current_community] -= 1;
181        self.counts[new_community] += 1;
182
183        new_community
184    }
185
186    fn move_node(&mut self, i: usize, degree: f64, target_community: usize) {
187        let current_community = self.belongings[i];
188        let loops = self.loops[i];
189
190        self.total_weights[current_community] -= degree + loops;
191        self.total_weights[target_community] += degree + loops;
192        self.belongings[i] = target_community;
193
194        let now_empty = self.counts[current_community] == 1;
195        self.counts[current_community] -= 1;
196        self.counts[target_community] += 1;
197
198        if now_empty {
199            self.unused[self.u] = current_community;
200            self.u += 1;
201        }
202    }
203
204    /// Fast delta computation (equivalent to graphology's fastDelta).
205    fn fast_delta(
206        &self,
207        i: usize,
208        degree: f64,
209        target_community_degree: f64,
210        target_community: usize,
211    ) -> f64 {
212        let m = self.m;
213        let target_total = self.total_weights[target_community];
214        let d = degree + self.loops[i];
215        target_community_degree - (d * target_total * self.resolution) / (2.0 * m)
216    }
217
218    /// Fast delta with own community (accounts for node being removed first).
219    fn fast_delta_with_own_community(
220        &self,
221        i: usize,
222        degree: f64,
223        target_community_degree: f64,
224        target_community: usize,
225    ) -> f64 {
226        let m = self.m;
227        let target_total = self.total_weights[target_community];
228        let d = degree + self.loops[i];
229        target_community_degree - (d * (target_total - d) * self.resolution) / (2.0 * m)
230    }
231
232    /// Coarsen the graph: merge nodes in same community into super-nodes.
233    fn zoom_out(&mut self) {
234        let n_original = self.nodes.len();
235        let mut new_labels: HashMap<usize, usize> = HashMap::new();
236
237        struct InducedNode {
238            adj: HashMap<usize, f64>,
239            total_weight: f64,
240            internal_weight: f64,
241        }
242
243        let mut induced: Vec<InducedNode> = Vec::new();
244        let mut c_new = 0usize;
245
246        // Renumber communities
247        for i in 0..self.c {
248            let ci = self.belongings[i];
249            if let std::collections::hash_map::Entry::Vacant(e) = new_labels.entry(ci) {
250                e.insert(c_new);
251                induced.push(InducedNode {
252                    adj: HashMap::new(),
253                    total_weight: self.total_weights[ci],
254                    internal_weight: 0.0,
255                });
256                c_new += 1;
257            }
258            self.belongings[i] = new_labels[&ci];
259        }
260
261        // Update mapping
262        for i in 0..n_original {
263            self.mapping[i] = self.belongings[self.mapping[i]];
264        }
265
266        // Build induced graph matrix
267        for i in 0..self.c {
268            let ci = self.belongings[i];
269            let data = &mut induced[ci];
270            data.internal_weight += self.loops[i];
271
272            for j in self.starts[i]..self.starts[i + 1] {
273                let n = self.neighborhood[j];
274                let cj = self.belongings[n];
275
276                if ci == cj {
277                    data.internal_weight += self.weights[j];
278                    continue;
279                }
280                *data.adj.entry(cj).or_insert(0.0) += self.weights[j];
281            }
282        }
283
284        // Rewrite neighborhood
285        self.c = c_new;
286        let mut n_edges = 0usize;
287        let mut edge_count = 0usize;
288
289        for (ci, data) in induced.iter().enumerate() {
290            self.total_weights[ci] = data.total_weight;
291            self.loops[ci] = data.internal_weight;
292            self.counts[ci] = 1;
293            self.starts[ci] = n_edges;
294            self.belongings[ci] = ci;
295
296            // Sort adj keys for deterministic order
297            let mut adj_keys: Vec<usize> = data.adj.keys().copied().collect();
298            adj_keys.sort();
299
300            for &cj in &adj_keys {
301                let w = data.adj[&cj];
302                if n_edges < self.neighborhood.len() {
303                    self.neighborhood[n_edges] = cj;
304                    self.weights[n_edges] = w;
305                }
306                edge_count += 1;
307                n_edges += 1;
308            }
309        }
310
311        self.starts[c_new] = edge_count;
312        self.e = edge_count;
313        self.u = 0;
314    }
315
316    /// Compute modularity of current partition.
317    fn modularity(&self) -> f64 {
318        let m2 = self.m * 2.0;
319        let mut internal_weights = vec![0.0f64; self.c];
320
321        for i in 0..self.c {
322            let ci = self.belongings[i];
323            internal_weights[ci] += self.loops[i];
324
325            for j in self.starts[i]..self.starts[i + 1] {
326                let cj = self.belongings[self.neighborhood[j]];
327                if ci == cj {
328                    internal_weights[ci] += self.weights[j];
329                }
330            }
331        }
332
333        let mut q = 0.0;
334        for (iw, tw) in internal_weights
335            .iter()
336            .zip(self.total_weights.iter())
337            .take(self.c)
338        {
339            q += iw / m2 - (tw / m2).powi(2) * self.resolution;
340        }
341        q
342    }
343
344    /// Collect the final community assignment: node name → community index.
345    fn collect(&self) -> HashMap<String, usize> {
346        let mut result = HashMap::new();
347        for (i, node) in self.nodes.iter().enumerate() {
348            result.insert(node.clone(), self.mapping[i]);
349        }
350        result
351    }
352}
353
354// ---------------------------------------------------------------------------
355// Sparse queue set for fast local moves
356// ---------------------------------------------------------------------------
357
358/// A FIFO queue that rejects duplicates (items already in the queue).
359struct SparseQueueSet {
360    queue: Vec<usize>,
361    head: usize,
362    in_set: Vec<bool>,
363    count: usize,
364}
365
366impl SparseQueueSet {
367    fn new(capacity: usize) -> Self {
368        Self {
369            queue: Vec::with_capacity(capacity),
370            head: 0,
371            in_set: vec![false; capacity],
372            count: 0,
373        }
374    }
375
376    fn enqueue(&mut self, item: usize) {
377        if !self.in_set[item] {
378            self.in_set[item] = true;
379            self.queue.push(item);
380            self.count += 1;
381        }
382    }
383
384    fn dequeue(&mut self) -> Option<usize> {
385        while self.head < self.queue.len() {
386            let item = self.queue[self.head];
387            self.head += 1;
388            if self.in_set[item] {
389                self.in_set[item] = false;
390                self.count -= 1;
391                return Some(item);
392            }
393        }
394        None
395    }
396
397    #[allow(dead_code)]
398    fn is_empty(&self) -> bool {
399        self.count == 0
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Louvain main loop
405// ---------------------------------------------------------------------------
406
407const EPSILON: f64 = 1e-10;
408
409fn tie_breaker(
410    best_community: usize,
411    current_community: usize,
412    target_community: usize,
413    delta: f64,
414    best_delta: f64,
415) -> bool {
416    if (delta - best_delta).abs() < EPSILON {
417        if best_community == current_community {
418            false
419        } else {
420            target_community > best_community
421        }
422    } else {
423        delta > best_delta
424    }
425}
426
427/// Run the Louvain algorithm with fast local moves and random walk.
428fn run_louvain(index: &mut LouvainIndex, rng: &mut dyn FnMut() -> f64) {
429    let mut move_was_made = true;
430    let mut queue;
431
432    while move_was_made {
433        let l = index.c;
434        move_was_made = false;
435
436        // Random walk starting index
437        let ri_start = (rng() * l as f64) as usize;
438
439        // Enqueue all nodes in random-walk order
440        queue = SparseQueueSet::new(l);
441        for s in 0..l {
442            let i = (ri_start + s) % l;
443            queue.enqueue(i);
444        }
445
446        // Per-node community weights — reused across iterations
447        let mut community_weights: HashMap<usize, f64> = HashMap::new();
448
449        while let Some(i) = queue.dequeue() {
450            let mut degree = 0.0;
451            community_weights.clear();
452
453            let current_community = index.belongings[i];
454            let start = index.starts[i];
455            let end = index.starts[i + 1];
456
457            // Traverse neighbors — accumulate degree and community weights
458            for pos in start..end {
459                let j = index.neighborhood[pos];
460                let weight = index.weights[pos];
461                let target_community = index.belongings[j];
462
463                degree += weight;
464                *community_weights.entry(target_community).or_insert(0.0) += weight;
465            }
466
467            // Find best community
468            let own_weight = community_weights
469                .get(&current_community)
470                .copied()
471                .unwrap_or(0.0);
472            let mut best_delta =
473                index.fast_delta_with_own_community(i, degree, own_weight, current_community);
474            let mut best_community = current_community;
475
476            // Sort community keys for deterministic iteration
477            let mut comm_keys: Vec<usize> = community_weights.keys().copied().collect();
478            comm_keys.sort();
479
480            for &target_community in &comm_keys {
481                if target_community == current_community {
482                    continue;
483                }
484                let target_degree = community_weights[&target_community];
485                let delta = index.fast_delta(i, degree, target_degree, target_community);
486
487                if tie_breaker(
488                    best_community,
489                    current_community,
490                    target_community,
491                    delta,
492                    best_delta,
493                ) {
494                    best_delta = delta;
495                    best_community = target_community;
496                }
497            }
498
499            // Should we move the node?
500            if best_delta < 0.0 {
501                // Move back to singleton
502                best_community = index.isolate(i, degree);
503                if best_community == current_community {
504                    continue;
505                }
506            } else if best_community == current_community {
507                continue;
508            } else {
509                index.move_node(i, degree, best_community);
510            }
511
512            move_was_made = true;
513
514            // Enqueue neighbors in other communities
515            let start = index.starts[i];
516            let end = index.starts[i + 1];
517            for pos in start..end {
518                let j = index.neighborhood[pos];
519                let target_community = index.belongings[j];
520                if target_community != best_community {
521                    queue.enqueue(j);
522                }
523            }
524        }
525
526        if move_was_made {
527            index.zoom_out();
528        }
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Public API
534// ---------------------------------------------------------------------------
535
536/// Detect communities using the Louvain algorithm.
537/// Uses a seeded PRNG for deterministic results.
538///
539/// `edge_weight_fn` maps relationship types to weights.
540/// Nodes where `store.get(id).stub == true` are excluded.
541/// `visible_fn` filters entities by mem visibility.
542pub fn detect_communities<F>(
543    store: &Store,
544    resolution: f64,
545    seed: u32,
546    edge_weight_fn: F,
547) -> LouvainOutput
548where
549    F: Fn(&str) -> f64,
550{
551    // Build undirected weighted graph from store
552    let mut node_ids: Vec<String> = Vec::new();
553    let mut node_index: HashMap<String, usize> = HashMap::new();
554
555    // Add visible, non-stub nodes
556    // Sort for deterministic iteration
557    let mut all_ids: Vec<&EntityId> = store.all_ids().collect();
558    all_ids.sort_by(|a, b| a.0.cmp(&b.0));
559
560    for id in &all_ids {
561        let entity = match store.get(id) {
562            Some(e) => e,
563            None => continue,
564        };
565        if entity.stub {
566            continue;
567        }
568        let idx = node_ids.len();
569        node_ids.push(id.0.clone());
570        node_index.insert(id.0.clone(), idx);
571    }
572
573    if node_ids.is_empty() {
574        return LouvainOutput {
575            modularity: 0.0,
576            count: 0,
577            clusters: HashMap::new(),
578            entity_cluster_map: HashMap::new(),
579        };
580    }
581
582    // Build weighted undirected edges — accumulate weights for multi-edges
583    let mut edge_map: HashMap<(usize, usize), f64> = HashMap::new();
584
585    for id in &all_ids {
586        let entity = match store.get(id) {
587            Some(e) if !e.stub => e,
588            _ => continue,
589        };
590
591        let src_idx = match node_index.get(&entity.id.0) {
592            Some(&i) => i,
593            None => continue,
594        };
595
596        for edge in store.outgoing(&entity.id) {
597            let tgt_idx = match node_index.get(&edge.target.0) {
598                Some(&i) => i,
599                None => continue,
600            };
601
602            let weight = edge_weight_fn(&edge.rel_type);
603
604            // Undirected: use canonical order (smaller, larger)
605            let key = if src_idx <= tgt_idx {
606                (src_idx, tgt_idx)
607            } else {
608                (tgt_idx, src_idx)
609            };
610            *edge_map.entry(key).or_insert(0.0) += weight;
611        }
612    }
613
614    let edges: Vec<(usize, usize, f64)> =
615        edge_map.into_iter().map(|((a, b), w)| (a, b, w)).collect();
616
617    // Single node or no edges — return one cluster
618    if node_ids.len() == 1 || edges.is_empty() {
619        let mut clusters = HashMap::new();
620        let mut entity_cluster_map = HashMap::new();
621        for id in &node_ids {
622            entity_cluster_map.insert(id.clone(), "0".to_string());
623        }
624        clusters.insert("0".to_string(), ClusterInfo { entities: node_ids });
625        return LouvainOutput {
626            modularity: 0.0,
627            count: 1,
628            clusters,
629            entity_cluster_map,
630        };
631    }
632
633    // Run Louvain
634    let mut index = LouvainIndex::new(node_ids, &edges, resolution);
635    let mut rng = mulberry32(seed);
636    run_louvain(&mut index, &mut rng);
637
638    let modularity = index.modularity();
639    let assignments = index.collect();
640
641    // Group by community ID
642    let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
643    let mut entity_cluster_map: HashMap<String, String> = HashMap::new();
644    for (node_id, community_id) in assignments {
645        let cluster_id = community_id.to_string();
646        entity_cluster_map.insert(node_id.clone(), cluster_id.clone());
647        grouped.entry(cluster_id).or_default().push(node_id);
648    }
649
650    let count = grouped.len();
651    let clusters = grouped
652        .into_iter()
653        .map(|(id, entities)| (id, ClusterInfo { entities }))
654        .collect();
655
656    LouvainOutput {
657        modularity,
658        count,
659        clusters,
660        entity_cluster_map,
661    }
662}
663
664/// Aggregate inter-cluster edges into undirected [`CommunityBridge`] entries.
665///
666/// Pair keys are lexicographically normalised (`from_cluster <= to_cluster`)
667/// so each unordered cluster pair appears at most once. Intra-cluster edges
668/// are skipped. If `mem_filter` is set, only edges whose **source** entity
669/// lives in that mem contribute — matches `memstead_health`'s asymmetric filter.
670/// Edges where either endpoint lacks a cluster assignment in `louvain` are
671/// skipped. `sample_edges` is capped at [`BRIDGE_SAMPLE_CAP`] and sorted by
672/// `(rel_type, from, to)` for determinism; `edge_types` is sorted ASCII.
673pub fn aggregate_bridges(
674    store: &Store,
675    louvain: &LouvainOutput,
676    mem_filter: Option<&str>,
677) -> Vec<CommunityBridge> {
678    // Accumulator per unordered cluster pair: (edge_count, edge_types, samples).
679    struct Acc {
680        edge_count: usize,
681        edge_types: BTreeSet<String>,
682        samples: Vec<SampleEdge>,
683    }
684    let mut by_pair: HashMap<(String, String), Acc> = HashMap::new();
685
686    let mut all_ids: Vec<&EntityId> = store.all_ids().collect();
687    all_ids.sort_by(|a, b| a.0.cmp(&b.0));
688
689    for id in &all_ids {
690        let entity = match store.get(id) {
691            Some(e) => e,
692            None => continue,
693        };
694        if let Some(vf) = mem_filter
695            && entity.mem != vf
696        {
697            continue;
698        }
699        let src_cluster = match louvain.entity_cluster_map.get(&entity.id.0) {
700            Some(c) => c.clone(),
701            None => continue,
702        };
703
704        for edge in store.outgoing(&entity.id) {
705            let tgt_cluster = match louvain.entity_cluster_map.get(&edge.target.0) {
706                Some(c) => c.clone(),
707                None => continue,
708            };
709            if src_cluster == tgt_cluster {
710                continue;
711            }
712
713            let (from_c, to_c) = if src_cluster <= tgt_cluster {
714                (src_cluster.clone(), tgt_cluster.clone())
715            } else {
716                (tgt_cluster.clone(), src_cluster.clone())
717            };
718
719            let entry = by_pair.entry((from_c, to_c)).or_insert_with(|| Acc {
720                edge_count: 0,
721                edge_types: BTreeSet::new(),
722                samples: Vec::new(),
723            });
724            entry.edge_count += 1;
725            entry.edge_types.insert(edge.rel_type.clone());
726            entry.samples.push(SampleEdge {
727                from: entity.id.0.clone(),
728                to: edge.target.0.clone(),
729                rel_type: edge.rel_type.clone(),
730            });
731        }
732    }
733
734    let mut bridges: Vec<CommunityBridge> = by_pair
735        .into_iter()
736        .map(|((from_cluster, to_cluster), mut acc)| {
737            acc.samples.sort_by(|a, b| {
738                a.rel_type
739                    .cmp(&b.rel_type)
740                    .then_with(|| a.from.cmp(&b.from))
741                    .then_with(|| a.to.cmp(&b.to))
742            });
743            acc.samples.truncate(BRIDGE_SAMPLE_CAP);
744            CommunityBridge {
745                from_cluster,
746                to_cluster,
747                edge_count: acc.edge_count,
748                edge_types: acc.edge_types.into_iter().collect(),
749                sample_edges: acc.samples,
750            }
751        })
752        .collect();
753    bridges.sort_by(|a, b| {
754        a.from_cluster
755            .cmp(&b.from_cluster)
756            .then_with(|| a.to_cluster.cmp(&b.to_cluster))
757    });
758    bridges
759}
760
761/// Cluster ids from `louvain` that have at least one member living in
762/// `mem`.
763///
764/// This is the membership rule that scopes `memstead_overview` /
765/// `memstead_health` community output under a `mem` filter: it filters the
766/// already-computed global partition rather than re-running detection on
767/// a subgraph (per-mem Louvain ≠ the global partition restricted to a
768/// mem, and would renumber clusters). Surviving cluster ids keep their
769/// global-pass values, and surviving clusters keep their full membership
770/// (including out-of-mem members) — only *which* clusters are reported
771/// is scoped, not their contents. Stub entities are ignored, matching the
772/// scoped entity-count definition.
773///
774/// Returned as a sorted set so callers iterate deterministically.
775pub fn clusters_in_mem(store: &Store, louvain: &LouvainOutput, mem: &str) -> BTreeSet<String> {
776    // Entity-id strings (the `.0` form used as cluster members) for the
777    // non-stub entities that live in `mem`.
778    let in_mem: HashSet<&str> = store
779        .all_entities()
780        .filter(|e| !e.stub && e.mem == mem)
781        .map(|e| e.id.0.as_str())
782        .collect();
783    louvain
784        .clusters
785        .iter()
786        .filter(|(_, info)| {
787            info.entities
788                .iter()
789                .any(|member| in_mem.contains(member.as_str()))
790        })
791        .map(|(cid, _)| cid.clone())
792        .collect()
793}
794
795/// Generate a default cluster summary by joining member entity titles.
796/// Called at render time (never stored) — see `render::render_overview_markdown`
797/// and `render::render_community_context_section`.
798pub fn generate_auto_summary(store: &Store, entities: &[String]) -> String {
799    entities
800        .iter()
801        .map(|eid| {
802            store
803                .get(&EntityId(eid.clone()))
804                .map(|e| e.title.as_str())
805                .unwrap_or(eid.as_str())
806        })
807        .collect::<Vec<_>>()
808        .join(" · ")
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::entity::Entity;
815    use crate::store::{Edge, EdgeSource};
816
817    fn entity(id: &str, mem: &str) -> Entity {
818        Entity {
819            id: EntityId(id.to_string()),
820            title: id.to_string(),
821            entity_type: "spec".to_string(),
822            mem: mem.to_string(),
823            file_path: String::new(),
824            metadata: indexmap::IndexMap::new(),
825            sections: indexmap::IndexMap::new(),
826            relationships: Vec::new(),
827            content_hash: String::new(),
828            stub: false,
829            stub_kind: None,
830            heading_spans: std::collections::HashMap::new(),
831            raw_section_headings: Vec::new(),
832        }
833    }
834
835    fn add_edge(store: &mut Store, from: &str, to: &str, rel: &str) {
836        store.add_edge(
837            EntityId(from.to_string()),
838            Edge {
839                rel_type: rel.to_string(),
840                target: EntityId(to.to_string()),
841                source: EdgeSource::Explicit,
842            },
843        );
844    }
845
846    // -----------------------------------------------------------------------
847    // PRNG cross-validation: 100 reference values from JS mulberry32(42)
848    // -----------------------------------------------------------------------
849
850    #[test]
851    fn mulberry32_cross_validation_with_js() {
852        let expected: [f64; 100] = [
853            0.6011037519201636,
854            0.44829055899754167,
855            0.8524657934904099,
856            0.6697340414393693,
857            0.17481389874592423,
858            0.5265925421845168,
859            0.2732279943302274,
860            0.6247446539346129,
861            0.8654746483080089,
862            0.4723170551005751,
863            0.24992373422719538,
864            0.8820588334929198,
865            0.7457375649828464,
866            0.3070015134289861,
867            0.19725383794866502,
868            0.5007294877432287,
869            0.6866120179183781,
870            0.6106208984274417,
871            0.003842951962724328,
872            0.47078192373737693,
873            0.8373374259099364,
874            0.05120926629751921,
875            0.5923239905387163,
876            0.03153795562684536,
877            0.2669559868518263,
878            0.06178139243274927,
879            0.18568900716491044,
880            0.7835472931619734,
881            0.530335606308654,
882            0.027123609324917197,
883            0.17300523445010185,
884            0.8426881253253669,
885            0.4877399173565209,
886            0.8090229837689549,
887            0.3194617456756532,
888            0.44989572861231863,
889            0.03743921360000968,
890            0.05139273451641202,
891            0.5565997792873532,
892            0.5967295127920806,
893            0.24517467361874878,
894            0.6456944046076387,
895            0.20951048866845667,
896            0.30362963443621993,
897            0.7386213727295399,
898            0.8587109192740172,
899            0.5079962892923504,
900            0.2041900979820639,
901            0.28420698270201683,
902            0.29299163701944053,
903            0.07469462975859642,
904            0.6598597934935242,
905            0.6807689322158694,
906            0.6930881165899336,
907            0.927839900366962,
908            0.08796802558936179,
909            0.9437352467793971,
910            0.43113749707117677,
911            0.942294550826773,
912            0.13729840773157775,
913            0.11094316560775042,
914            0.015842905268073082,
915            0.3604456859175116,
916            0.48172251763753593,
917            0.611922019161284,
918            0.8995579732581973,
919            0.07758240564726293,
920            0.7195980150718242,
921            0.934867731994018,
922            0.2782514716964215,
923            0.7065853946842253,
924            0.1796227190643549,
925            0.5203163539990783,
926            0.8218917581252754,
927            0.12059257342480123,
928            0.9956386350095272,
929            0.562800214625895,
930            0.9161201647948474,
931            0.4339903697837144,
932            0.5784530241508037,
933            0.3369620821904391,
934            0.5962391037028283,
935            0.3229577951133251,
936            0.7294139908626676,
937            0.2952086783479899,
938            0.4497627364471555,
939            0.8431257682386786,
940            0.6950652054511011,
941            0.9940114878118038,
942            0.8901981303934008,
943            0.431891469983384,
944            0.5452131326310337,
945            0.29592951526865363,
946            0.1008774396032095,
947            0.6967215123586357,
948            0.3133056035730988,
949            0.7859425814822316,
950            0.9047754912171513,
951            0.09364134701900184,
952            0.47539179865270853,
953        ];
954
955        let mut rng = mulberry32(42);
956        for (i, &exp) in expected.iter().enumerate() {
957            let got = rng();
958            assert!(
959                (got - exp).abs() < 1e-15,
960                "PRNG mismatch at index {i}: expected {exp}, got {got}"
961            );
962        }
963    }
964
965    // -----------------------------------------------------------------------
966    // Community detection tests
967    // -----------------------------------------------------------------------
968
969    #[test]
970    fn detect_empty_store() {
971        let store = Store::new();
972        let result = detect_communities(&store, 1.0, 42, |_| 1.0);
973        assert_eq!(result.count, 0);
974        assert!(result.clusters.is_empty());
975    }
976
977    #[test]
978    fn detect_single_node() {
979        let mut store = Store::new();
980        store.upsert(EntityId("a".into()), entity("a", "s"));
981        let result = detect_communities(&store, 1.0, 42, |_| 1.0);
982        assert_eq!(result.count, 1);
983        assert_eq!(result.clusters.len(), 1);
984    }
985
986    #[test]
987    fn detect_disconnected_nodes() {
988        let mut store = Store::new();
989        store.upsert(EntityId("a".into()), entity("a", "s"));
990        store.upsert(EntityId("b".into()), entity("b", "s"));
991        // No edges
992        let result = detect_communities(&store, 1.0, 42, |_| 1.0);
993        assert_eq!(result.count, 1);
994        // All disconnected nodes end up in one "cluster"
995    }
996
997    #[test]
998    fn detect_two_clusters() {
999        let mut store = Store::new();
1000        // Cluster 1: a-b-c (triangle)
1001        store.upsert(EntityId("a".into()), entity("a", "s"));
1002        store.upsert(EntityId("b".into()), entity("b", "s"));
1003        store.upsert(EntityId("c".into()), entity("c", "s"));
1004        add_edge(&mut store, "a", "b", "USES");
1005        add_edge(&mut store, "b", "c", "USES");
1006        add_edge(&mut store, "c", "a", "USES");
1007
1008        // Cluster 2: d-e-f (triangle)
1009        store.upsert(EntityId("d".into()), entity("d", "s"));
1010        store.upsert(EntityId("e".into()), entity("e", "s"));
1011        store.upsert(EntityId("f".into()), entity("f", "s"));
1012        add_edge(&mut store, "d", "e", "USES");
1013        add_edge(&mut store, "e", "f", "USES");
1014        add_edge(&mut store, "f", "d", "USES");
1015
1016        // Weak bridge between clusters
1017        add_edge(&mut store, "c", "d", "REFERENCES");
1018
1019        let result = detect_communities(&store, 1.0, 42, |_| 1.0);
1020        assert!(
1021            result.count >= 2,
1022            "Expected at least 2 clusters, got {}",
1023            result.count
1024        );
1025
1026        // Verify a, b, c are in the same community
1027        let node_to_cluster = &result.entity_cluster_map;
1028
1029        assert_eq!(node_to_cluster["a"], node_to_cluster["b"]);
1030        assert_eq!(node_to_cluster["b"], node_to_cluster["c"]);
1031        assert_eq!(node_to_cluster["d"], node_to_cluster["e"]);
1032        assert_eq!(node_to_cluster["e"], node_to_cluster["f"]);
1033        assert_ne!(node_to_cluster["a"], node_to_cluster["d"]);
1034    }
1035
1036    #[test]
1037    fn detect_skips_stubs() {
1038        let mut store = Store::new();
1039        store.upsert(EntityId("real".into()), entity("real", "s"));
1040        let mut stub = entity("stub", "s");
1041        stub.stub = true;
1042        store.upsert(EntityId("stub".into()), stub);
1043        add_edge(&mut store, "real", "stub", "REFERENCES");
1044
1045        let result = detect_communities(&store, 1.0, 42, |_| 1.0);
1046        // Only the real entity should be in communities
1047        let total_entities: usize = result.clusters.values().map(|c| c.entities.len()).sum();
1048        assert_eq!(total_entities, 1);
1049    }
1050
1051    #[test]
1052    fn detect_deterministic() {
1053        let mut store = Store::new();
1054        for i in 0..10 {
1055            store.upsert(EntityId(format!("e{i}")), entity(&format!("e{i}"), "s"));
1056        }
1057        for i in 0..9 {
1058            add_edge(&mut store, &format!("e{i}"), &format!("e{}", i + 1), "USES");
1059        }
1060
1061        let r1 = detect_communities(&store, 1.0, 42, |_| 1.0);
1062        let r2 = detect_communities(&store, 1.0, 42, |_| 1.0);
1063
1064        assert_eq!(r1.count, r2.count);
1065        assert!(
1066            (r1.modularity - r2.modularity).abs() < 1e-10,
1067            "Modularity mismatch: {} vs {}",
1068            r1.modularity,
1069            r2.modularity
1070        );
1071    }
1072
1073    #[test]
1074    fn detect_respects_edge_weights() {
1075        let mut store = Store::new();
1076        store.upsert(EntityId("a".into()), entity("a", "s"));
1077        store.upsert(EntityId("b".into()), entity("b", "s"));
1078        store.upsert(EntityId("c".into()), entity("c", "s"));
1079        add_edge(&mut store, "a", "b", "IMPLEMENTS"); // weight 2.0
1080        add_edge(&mut store, "b", "c", "REFERENCES"); // weight 1.0
1081
1082        let result = detect_communities(&store, 1.0, 42, |rel| match rel {
1083            "IMPLEMENTS" => 2.0,
1084            _ => 1.0,
1085        });
1086        // With 3 nodes the algorithm should find 1 cluster
1087        assert!(result.count >= 1);
1088    }
1089
1090    // -----------------------------------------------------------------------
1091    // Sparse queue set tests
1092    // -----------------------------------------------------------------------
1093
1094    #[test]
1095    fn sparse_queue_set_basic() {
1096        let mut q = SparseQueueSet::new(5);
1097        q.enqueue(0);
1098        q.enqueue(1);
1099        q.enqueue(0); // duplicate — should be ignored
1100        assert_eq!(q.dequeue(), Some(0));
1101        assert_eq!(q.dequeue(), Some(1));
1102        assert_eq!(q.dequeue(), None);
1103    }
1104
1105    #[test]
1106    fn sparse_queue_set_fifo_order() {
1107        let mut q = SparseQueueSet::new(10);
1108        q.enqueue(3);
1109        q.enqueue(1);
1110        q.enqueue(4);
1111        assert_eq!(q.dequeue(), Some(3));
1112        assert_eq!(q.dequeue(), Some(1));
1113        assert_eq!(q.dequeue(), Some(4));
1114    }
1115
1116    #[test]
1117    fn sparse_queue_set_re_enqueue_after_dequeue() {
1118        let mut q = SparseQueueSet::new(5);
1119        q.enqueue(0);
1120        assert_eq!(q.dequeue(), Some(0));
1121        q.enqueue(0); // should work after dequeue
1122        assert_eq!(q.dequeue(), Some(0));
1123    }
1124}