1use super::{BRIDGE_SAMPLE_CAP, ClusterInfo, CommunityBridge, LouvainOutput, SampleEdge};
8use crate::entity::EntityId;
9use crate::store::Store;
10use std::collections::{BTreeSet, HashMap, HashSet};
11
12pub 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 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
31struct LouvainIndex {
38 c: usize, m: f64, e: usize, u: usize, resolution: f64,
44 nodes: Vec<String>, neighborhood: Vec<usize>, weights: Vec<f64>, loops: Vec<f64>, starts: Vec<usize>, belongings: Vec<usize>, mapping: Vec<usize>, counts: Vec<usize>, unused: Vec<usize>, total_weights: Vec<f64>, }
61
62impl LouvainIndex {
63 fn new(nodes: Vec<String>, edges: &[(usize, usize, f64)], resolution: f64) -> Self {
68 let n = nodes.len();
69
70 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 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 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)] 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 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 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 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 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 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 for i in 0..n_original {
263 self.mapping[i] = self.belongings[self.mapping[i]];
264 }
265
266 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 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 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 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 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
354struct 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
403const 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
427fn 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 let ri_start = (rng() * l as f64) as usize;
438
439 queue = SparseQueueSet::new(l);
441 for s in 0..l {
442 let i = (ri_start + s) % l;
443 queue.enqueue(i);
444 }
445
446 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 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 let own_weight = community_weights
469 .get(¤t_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 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 if best_delta < 0.0 {
501 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 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
532pub 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 let mut node_ids: Vec<String> = Vec::new();
553 let mut node_index: HashMap<String, usize> = HashMap::new();
554
555 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 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 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 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 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 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
664pub fn aggregate_bridges(
674 store: &Store,
675 louvain: &LouvainOutput,
676 mem_filter: Option<&str>,
677) -> Vec<CommunityBridge> {
678 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
761pub fn clusters_in_mem(store: &Store, louvain: &LouvainOutput, mem: &str) -> BTreeSet<String> {
776 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
795pub 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 #[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 #[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 let result = detect_communities(&store, 1.0, 42, |_| 1.0);
993 assert_eq!(result.count, 1);
994 }
996
997 #[test]
998 fn detect_two_clusters() {
999 let mut store = Store::new();
1000 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 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 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 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 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"); add_edge(&mut store, "b", "c", "REFERENCES"); let result = detect_communities(&store, 1.0, 42, |rel| match rel {
1083 "IMPLEMENTS" => 2.0,
1084 _ => 1.0,
1085 });
1086 assert!(result.count >= 1);
1088 }
1089
1090 #[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); 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); assert_eq!(q.dequeue(), Some(0));
1123 }
1124}