1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5
6use code_system_graph_model::{
7 Community, CommunityAlgorithm, CommunityChange, CommunityChangeKind, CommunityConfig, CommunityDelta, CommunityEdgeWeight, CommunityId, CommunityLabelEvidence, CommunityMetrics, CommunityScope, CommunitySnapshot, Edge, EdgeKind, EpistemicStatus, Node, NodeId, NodeKind, RepoId, stable_id
8};
9use thiserror::Error;
10
11const ENGINE_VERSION: &str = "1.0.0";
12const EPSILON: f64 = 1.0e-12;
13const PAGE_RANK_DAMPING: f64 = 0.85;
14const PAGE_RANK_ITERATIONS: usize = 64;
15const RELATED_OVERLAP: f64 = 0.25;
16const STABLE_OVERLAP: f64 = 0.80;
17const EDGE_KIND_COUNT: usize = 25;
18
19#[derive(Debug, Error, PartialEq)]
21pub enum CommunityError {
22 #[error("community resolution must be finite and positive, found {0}")]
24 InvalidResolution(f64),
25 #[error("community max_iterations must be in 1..=10_000, found {0}")]
27 InvalidMaxIterations(u32),
28 #[error("community minimum_confidence must be finite and in 0..=1, found {0}")]
30 InvalidMinimumConfidence(f32),
31 #[error("edge `{edge_id}` confidence must be finite and in 0..=1, found {confidence}")]
33 InvalidEdgeConfidence {
34 edge_id: String,
36 confidence: f32,
38 },
39 #[error("community edge weight for `{kind:?}` must be finite and non-negative, found {weight}")]
41 InvalidEdgeWeight {
42 kind: EdgeKind,
44 weight: f64,
46 },
47 #[error("community edge weight for `{0:?}` is configured more than once")]
49 DuplicateEdgeWeight(EdgeKind),
50 #[error("node identifier `{0}` occurs more than once")]
52 DuplicateNodeId(String),
53 #[error("service scope stable key `{0}` does not identify a service node")]
55 UnknownService(String),
56}
57
58#[derive(Debug, Clone)]
59struct AcceptedEdge {
60 source: usize,
61 target: usize,
62 weight: f64,
63}
64
65#[derive(Debug)]
66struct WeightedGraph {
67 nodes: Vec<Node>,
68 adjacency: Vec<BTreeMap<usize, f64>>,
69 undirected_edges: Vec<(usize, usize, f64)>,
70 accepted_edges: Vec<AcceptedEdge>,
71 weighted_degree: Vec<f64>,
72 neighbor_count: Vec<usize>,
73 total_undirected_weight: f64,
74}
75
76pub fn analyze_communities(
89 snapshot_id: impl Into<String>,
90 nodes: &[Node],
91 edges: &[Edge],
92 config: CommunityConfig,
93) -> Result<CommunitySnapshot, CommunityError> {
94 analyze_communities_with_progress(snapshot_id, nodes, edges, config, |_| {})
95}
96
97pub fn analyze_communities_with_progress<F>(
103 snapshot_id: impl Into<String>,
104 nodes: &[Node],
105 edges: &[Edge],
106 config: CommunityConfig,
107 mut progress: F,
108) -> Result<CommunitySnapshot, CommunityError>
109where
110 F: FnMut(u64),
111{
112 let weights = validate_inputs(nodes, edges, &config)?;
113 let selected = scoped_node_ids(nodes, edges, &config, &weights)?;
114 let graph = build_graph(nodes, edges, &config, &weights, &selected);
115 let assignments = match config.algorithm {
116 CommunityAlgorithm::ConnectedComponents => connected_components(&graph, &mut progress),
117 CommunityAlgorithm::WeightedClustering => {
118 weighted_label_propagation(&graph, config.seed, config.max_iterations, &mut progress)
119 }
120 CommunityAlgorithm::Louvain => louvain(
121 &graph,
122 config.seed,
123 config.resolution,
124 config.max_iterations,
125 &mut progress,
126 ),
127 };
128 let communities = describe_communities(&graph, &assignments);
129
130 Ok(CommunitySnapshot {
131 snapshot_id: snapshot_id.into(),
132 engine_version: ENGINE_VERSION.to_owned(),
133 config,
134 communities,
135 })
136}
137
138#[must_use]
145pub fn compare_community_snapshots(
146 before: &CommunitySnapshot,
147 after: &CommunitySnapshot,
148) -> CommunityDelta {
149 let overlaps = overlap_matrix(&before.communities, &after.communities);
150 let before_related =
151 related_after(&overlaps, before.communities.len(), after.communities.len());
152 let after_related =
153 related_before(&overlaps, before.communities.len(), after.communities.len());
154 let (mut changes, mut matched_before, mut matched_after) =
155 classify_structural_changes(before, after, &overlaps, &before_related, &after_related);
156 classify_pairwise_changes(
157 before,
158 after,
159 &overlaps,
160 &mut matched_before,
161 &mut matched_after,
162 &mut changes,
163 );
164 classify_unmatched(before, after, &matched_before, &matched_after, &mut changes);
165 changes.sort_by(compare_changes);
166 CommunityDelta {
167 before_snapshot_id: before.snapshot_id.clone(),
168 after_snapshot_id: after.snapshot_id.clone(),
169 changes,
170 }
171}
172
173fn classify_structural_changes(
174 before: &CommunitySnapshot,
175 after: &CommunitySnapshot,
176 overlaps: &BTreeMap<(usize, usize), f64>,
177 before_related: &[Vec<usize>],
178 after_related: &[Vec<usize>],
179) -> (Vec<CommunityChange>, BTreeSet<usize>, BTreeSet<usize>) {
180 let mut changes = Vec::new();
181 let mut matched_before = BTreeSet::new();
182 let mut matched_after = BTreeSet::new();
183 for (before_index, related) in before_related.iter().enumerate() {
184 if related.len() >= 2 {
185 matched_before.insert(before_index);
186 matched_after.extend(related.iter().copied());
187 changes.push(CommunityChange {
188 kind: CommunityChangeKind::Split,
189 before: vec![before.communities[before_index].id.clone()],
190 after: related
191 .iter()
192 .map(|index| after.communities[*index].id.clone())
193 .collect(),
194 overlap: maximum_overlap(overlaps, related, |index| (before_index, index)),
195 explanation: format!(
196 "One prior community overlaps {} successor communities at or above {:.2}.",
197 related.len(),
198 RELATED_OVERLAP
199 ),
200 });
201 }
202 }
203 for (after_index, related) in after_related.iter().enumerate() {
204 if related.len() >= 2 {
205 matched_after.insert(after_index);
206 matched_before.extend(related.iter().copied());
207 changes.push(CommunityChange {
208 kind: CommunityChangeKind::Merged,
209 before: related
210 .iter()
211 .map(|index| before.communities[*index].id.clone())
212 .collect(),
213 after: vec![after.communities[after_index].id.clone()],
214 overlap: maximum_overlap(overlaps, related, |index| (index, after_index)),
215 explanation: format!(
216 "{} prior communities overlap one successor at or above {:.2}.",
217 related.len(),
218 RELATED_OVERLAP
219 ),
220 });
221 }
222 }
223 (changes, matched_before, matched_after)
224}
225
226fn maximum_overlap(
227 overlaps: &BTreeMap<(usize, usize), f64>,
228 related: &[usize],
229 key: impl Fn(usize) -> (usize, usize),
230) -> f64 {
231 related
232 .iter()
233 .filter_map(|&index| overlaps.get(&key(index)).copied())
234 .fold(0.0, f64::max)
235}
236
237fn classify_pairwise_changes(
238 before: &CommunitySnapshot,
239 after: &CommunitySnapshot,
240 overlaps: &BTreeMap<(usize, usize), f64>,
241 matched_before: &mut BTreeSet<usize>,
242 matched_after: &mut BTreeSet<usize>,
243 changes: &mut Vec<CommunityChange>,
244) {
245 let mut candidates: Vec<(usize, usize, f64)> = overlaps
246 .iter()
247 .filter(|(_, overlap)| **overlap >= RELATED_OVERLAP)
248 .map(|(&(left, right), &overlap)| (left, right, overlap))
249 .collect();
250 candidates.sort_by(|left, right| {
251 right
252 .2
253 .total_cmp(&left.2)
254 .then_with(|| {
255 before.communities[left.0]
256 .id
257 .cmp(&before.communities[right.0].id)
258 })
259 .then_with(|| {
260 after.communities[left.1]
261 .id
262 .cmp(&after.communities[right.1].id)
263 })
264 });
265 for (before_index, after_index, overlap) in candidates {
266 if matched_before.contains(&before_index) || matched_after.contains(&after_index) {
267 continue;
268 }
269 matched_before.insert(before_index);
270 matched_after.insert(after_index);
271 if overlap + EPSILON < STABLE_OVERLAP {
272 changes.push(CommunityChange {
273 kind: CommunityChangeKind::MateriallyChanged,
274 before: vec![before.communities[before_index].id.clone()],
275 after: vec![after.communities[after_index].id.clone()],
276 overlap,
277 explanation: format!("Best one-to-one member overlap is {overlap:.3}, below the stable threshold {STABLE_OVERLAP:.2}."),
278 });
279 }
280 }
281}
282
283fn classify_unmatched(
284 before: &CommunitySnapshot,
285 after: &CommunitySnapshot,
286 matched_before: &BTreeSet<usize>,
287 matched_after: &BTreeSet<usize>,
288 changes: &mut Vec<CommunityChange>,
289) {
290 for (index, community) in before.communities.iter().enumerate() {
291 if !matched_before.contains(&index) {
292 changes.push(CommunityChange {
293 kind: CommunityChangeKind::Removed,
294 before: vec![community.id.clone()],
295 after: Vec::new(),
296 overlap: 0.0,
297 explanation: format!("No successor community reaches the related-overlap threshold {RELATED_OVERLAP:.2}."),
298 });
299 }
300 }
301 for (index, community) in after.communities.iter().enumerate() {
302 if !matched_after.contains(&index) {
303 changes.push(CommunityChange {
304 kind: CommunityChangeKind::Created,
305 before: Vec::new(),
306 after: vec![community.id.clone()],
307 overlap: 0.0,
308 explanation: format!(
309 "No prior community reaches the related-overlap threshold {RELATED_OVERLAP:.2}."
310 ),
311 });
312 }
313 }
314}
315
316fn validate_inputs(
317 nodes: &[Node],
318 edges: &[Edge],
319 config: &CommunityConfig,
320) -> Result<[f64; EDGE_KIND_COUNT], CommunityError> {
321 if !config.resolution.is_finite() || config.resolution <= 0.0 {
322 return Err(CommunityError::InvalidResolution(config.resolution));
323 }
324 if !(1..=10_000).contains(&config.max_iterations) {
325 return Err(CommunityError::InvalidMaxIterations(config.max_iterations));
326 }
327 if !config.minimum_confidence.is_finite() || !(0.0..=1.0).contains(&config.minimum_confidence) {
328 return Err(CommunityError::InvalidMinimumConfidence(
329 config.minimum_confidence,
330 ));
331 }
332 let mut node_ids = BTreeSet::new();
333 for node in nodes {
334 if !node_ids.insert(node.id.as_str()) {
335 return Err(CommunityError::DuplicateNodeId(node.id.as_str().to_owned()));
336 }
337 }
338 for edge in edges {
339 if !edge.confidence.is_finite() || !(0.0..=1.0).contains(&edge.confidence) {
340 return Err(CommunityError::InvalidEdgeConfidence {
341 edge_id: edge.id.as_str().to_owned(),
342 confidence: edge.confidence,
343 });
344 }
345 }
346
347 let mut weights = [1.0; EDGE_KIND_COUNT];
348 let mut configured = [false; EDGE_KIND_COUNT];
349 for CommunityEdgeWeight { kind, weight } in &config.edge_weights {
350 if !weight.is_finite() || *weight < 0.0 {
351 return Err(CommunityError::InvalidEdgeWeight {
352 kind: *kind,
353 weight: *weight,
354 });
355 }
356 let index = edge_kind_index(*kind);
357 if configured[index] {
358 return Err(CommunityError::DuplicateEdgeWeight(*kind));
359 }
360 configured[index] = true;
361 weights[index] = *weight;
362 }
363 Ok(weights)
364}
365
366fn scoped_node_ids(
367 nodes: &[Node],
368 edges: &[Edge],
369 config: &CommunityConfig,
370 weights: &[f64; EDGE_KIND_COUNT],
371) -> Result<BTreeSet<NodeId>, CommunityError> {
372 match &config.scope {
373 CommunityScope::Federated | CommunityScope::Workspace => {
374 Ok(nodes.iter().map(|node| node.id.clone()).collect())
375 }
376 CommunityScope::Repository(repo_id) => Ok(nodes
377 .iter()
378 .filter(|node| node.repo_id.as_ref() == Some(repo_id))
379 .map(|node| node.id.clone())
380 .collect()),
381 CommunityScope::Service(stable_key) => {
382 let services: BTreeSet<NodeId> = nodes
383 .iter()
384 .filter(|node| {
385 node.kind == NodeKind::Service && node.stable_key.as_str() == stable_key
386 })
387 .map(|node| node.id.clone())
388 .collect();
389 if services.is_empty() {
390 return Err(CommunityError::UnknownService(stable_key.clone()));
391 }
392 let known: BTreeSet<&str> = nodes.iter().map(|node| node.id.as_str()).collect();
393 let mut selected = services.clone();
394 for edge in edges {
395 if edge.status != EpistemicStatus::Confirmed
396 || edge.confidence < config.minimum_confidence
397 || weights[edge_kind_index(edge.kind)] <= 0.0
398 || !known.contains(edge.source.as_str())
399 || !known.contains(edge.target.as_str())
400 {
401 continue;
402 }
403 if services.contains(&edge.source) {
404 selected.insert(edge.target.clone());
405 }
406 if services.contains(&edge.target) {
407 selected.insert(edge.source.clone());
408 }
409 }
410 Ok(selected)
411 }
412 }
413}
414
415fn build_graph(
416 nodes: &[Node],
417 edges: &[Edge],
418 config: &CommunityConfig,
419 weights: &[f64; EDGE_KIND_COUNT],
420 selected: &BTreeSet<NodeId>,
421) -> WeightedGraph {
422 let mut graph_nodes: Vec<Node> = nodes
423 .iter()
424 .filter(|node| selected.contains(&node.id))
425 .cloned()
426 .collect();
427 graph_nodes.sort_by(|left, right| left.id.cmp(&right.id));
428 let index: BTreeMap<&str, usize> = graph_nodes
429 .iter()
430 .enumerate()
431 .map(|(position, node)| (node.id.as_str(), position))
432 .collect();
433 let mut ordered_edges: Vec<&Edge> = edges.iter().collect();
434 ordered_edges.sort_by(|left, right| {
435 left.source
436 .cmp(&right.source)
437 .then_with(|| left.target.cmp(&right.target))
438 .then_with(|| edge_kind_index(left.kind).cmp(&edge_kind_index(right.kind)))
439 .then_with(|| left.id.cmp(&right.id))
440 .then_with(|| left.confidence.total_cmp(&right.confidence))
441 });
442
443 let mut accepted_edges = Vec::new();
444 let mut undirected = BTreeMap::<(usize, usize), f64>::new();
445 for edge in ordered_edges {
446 if edge.status != EpistemicStatus::Confirmed || edge.confidence < config.minimum_confidence
447 {
448 continue;
449 }
450 let Some(&source) = index.get(edge.source.as_str()) else {
451 continue;
452 };
453 let Some(&target) = index.get(edge.target.as_str()) else {
454 continue;
455 };
456 let weight = weights[edge_kind_index(edge.kind)] * f64::from(edge.confidence);
457 if weight <= 0.0 {
458 continue;
459 }
460 accepted_edges.push(AcceptedEdge {
461 source,
462 target,
463 weight,
464 });
465 let pair = if source <= target {
466 (source, target)
467 } else {
468 (target, source)
469 };
470 *undirected.entry(pair).or_default() += weight;
471 }
472
473 let undirected_edges: Vec<(usize, usize, f64)> = undirected
474 .into_iter()
475 .map(|((source, target), weight)| (source, target, weight))
476 .collect();
477 let mut adjacency = vec![BTreeMap::new(); graph_nodes.len()];
478 let mut weighted_degree = vec![0.0; graph_nodes.len()];
479 let mut neighbors = vec![BTreeSet::new(); graph_nodes.len()];
480 let mut total_undirected_weight = 0.0;
481 for &(source, target, weight) in &undirected_edges {
482 total_undirected_weight += weight;
483 if source == target {
484 adjacency[source].insert(target, weight);
485 weighted_degree[source] += 2.0 * weight;
486 } else {
487 adjacency[source].insert(target, weight);
488 adjacency[target].insert(source, weight);
489 weighted_degree[source] += weight;
490 weighted_degree[target] += weight;
491 neighbors[source].insert(target);
492 neighbors[target].insert(source);
493 }
494 }
495 let neighbor_count = neighbors.into_iter().map(|items| items.len()).collect();
496 WeightedGraph {
497 nodes: graph_nodes,
498 adjacency,
499 undirected_edges,
500 accepted_edges,
501 weighted_degree,
502 neighbor_count,
503 total_undirected_weight,
504 }
505}
506
507fn connected_components<F>(graph: &WeightedGraph, progress: &mut F) -> Vec<usize>
508where
509 F: FnMut(u64),
510{
511 let mut assignments = vec![usize::MAX; graph.nodes.len()];
512 let mut component = 0;
513 for start in 0..graph.nodes.len() {
514 if assignments[start] != usize::MAX {
515 continue;
516 }
517 assignments[start] = component;
518 let mut pending = vec![start];
519 while let Some(node) = pending.pop() {
520 for (&neighbor, &weight) in &graph.adjacency[node] {
521 if weight > 0.0 && assignments[neighbor] == usize::MAX {
522 assignments[neighbor] = component;
523 pending.push(neighbor);
524 }
525 }
526 }
527 component += 1;
528 progress(1);
529 }
530 assignments
531}
532
533fn weighted_label_propagation<F>(
534 graph: &WeightedGraph,
535 seed: u64,
536 max_iterations: u32,
537 progress: &mut F,
538) -> Vec<usize>
539where
540 F: FnMut(u64),
541{
542 let mut assignments: Vec<usize> = (0..graph.nodes.len()).collect();
543 let order = seeded_node_order(graph, seed);
544 for _ in 0..max_iterations {
545 let mut changed = false;
546 for &node in &order {
547 let mut scores = BTreeMap::<usize, f64>::new();
548 for (&neighbor, &weight) in &graph.adjacency[node] {
549 if neighbor != node {
550 *scores.entry(assignments[neighbor]).or_default() += weight;
551 }
552 }
553 let current = assignments[node];
554 let current_score = scores.get(¤t).copied().unwrap_or_default();
555 let mut best = current;
556 let mut best_score = current_score;
557 for (candidate, score) in scores {
558 if score > best_score + EPSILON
559 || ((score - best_score).abs() <= EPSILON
560 && score > current_score + EPSILON
561 && seeded_label_key(graph, seed, candidate)
562 < seeded_label_key(graph, seed, best))
563 {
564 best = candidate;
565 best_score = score;
566 }
567 }
568 if best != current {
569 assignments[node] = best;
570 changed = true;
571 }
572 }
573 progress(1);
574 if !changed {
575 break;
576 }
577 }
578 assignments
579}
580
581fn louvain<F>(
582 graph: &WeightedGraph,
583 seed: u64,
584 resolution: f64,
585 max_iterations: u32,
586 progress: &mut F,
587) -> Vec<usize>
588where
589 F: FnMut(u64),
590{
591 let mut assignments: Vec<usize> = (0..graph.nodes.len()).collect();
592 if graph.total_undirected_weight <= EPSILON {
593 return assignments;
594 }
595 let order = seeded_node_order(graph, seed);
596 for _ in 0..max_iterations {
597 let mut changed = false;
598 for &node in &order {
599 let current = assignments[node];
600 let baseline = modularity(graph, &assignments, resolution);
601 let mut candidates = BTreeSet::from([current]);
602 for &neighbor in graph.adjacency[node].keys() {
603 candidates.insert(assignments[neighbor]);
604 }
605 if let Some(empty) = first_empty_label(&assignments) {
606 candidates.insert(empty);
607 }
608 let mut best = current;
609 let mut best_modularity = baseline;
610 for candidate in candidates {
611 if candidate == current {
612 continue;
613 }
614 assignments[node] = candidate;
615 let candidate_modularity = modularity(graph, &assignments, resolution);
616 assignments[node] = current;
617 if candidate_modularity > best_modularity + EPSILON
618 || ((candidate_modularity - best_modularity).abs() <= EPSILON
619 && candidate_modularity > baseline + EPSILON
620 && seeded_label_key(graph, seed, candidate)
621 < seeded_label_key(graph, seed, best))
622 {
623 best = candidate;
624 best_modularity = candidate_modularity;
625 }
626 }
627 if best != current && best_modularity > baseline + EPSILON {
628 assignments[node] = best;
629 changed = true;
630 }
631 }
632 progress(1);
633 if !changed {
634 break;
635 }
636 }
637 assignments
638}
639
640fn modularity(graph: &WeightedGraph, assignments: &[usize], resolution: f64) -> f64 {
643 let total = graph.total_undirected_weight;
644 if total <= EPSILON {
645 return 0.0;
646 }
647 let mut internal = BTreeMap::<usize, f64>::new();
648 let mut degree = BTreeMap::<usize, f64>::new();
649 for (node, &community) in assignments.iter().enumerate() {
650 *degree.entry(community).or_default() += graph.weighted_degree[node];
651 }
652 for &(source, target, weight) in &graph.undirected_edges {
653 if assignments[source] == assignments[target] {
654 *internal.entry(assignments[source]).or_default() += weight;
655 }
656 }
657 degree
658 .into_iter()
659 .map(|(community, community_degree)| {
660 let inside = internal.get(&community).copied().unwrap_or_default();
661 inside / total - resolution * (community_degree / (2.0 * total)).powi(2)
662 })
663 .sum()
664}
665
666fn first_empty_label(assignments: &[usize]) -> Option<usize> {
667 let mut used = vec![false; assignments.len()];
668 for &assignment in assignments {
669 if let Some(slot) = used.get_mut(assignment) {
670 *slot = true;
671 }
672 }
673 used.iter().position(|value| !value)
674}
675
676fn seeded_node_order(graph: &WeightedGraph, seed: u64) -> Vec<usize> {
677 let mut order: Vec<usize> = (0..graph.nodes.len()).collect();
678 order.sort_by(|&left, &right| {
679 seeded_key(seed, graph.nodes[left].id.as_str())
680 .cmp(&seeded_key(seed, graph.nodes[right].id.as_str()))
681 .then_with(|| graph.nodes[left].id.cmp(&graph.nodes[right].id))
682 });
683 order
684}
685
686fn seeded_label_key(graph: &WeightedGraph, seed: u64, label: usize) -> String {
687 graph.nodes.get(label).map_or_else(
688 || stable_id("community-order", &format!("{seed}:empty:{label}")),
689 |node| seeded_key(seed, node.id.as_str()),
690 )
691}
692
693fn seeded_key(seed: u64, value: &str) -> String {
694 stable_id("community-order", &format!("{seed}:{value}"))
695}
696
697fn describe_communities(graph: &WeightedGraph, assignments: &[usize]) -> Vec<Community> {
698 let mut grouped = BTreeMap::<usize, Vec<usize>>::new();
699 for (node, &community) in assignments.iter().enumerate() {
700 grouped.entry(community).or_default().push(node);
701 }
702 let mut groups: Vec<Vec<usize>> = grouped.into_values().collect();
703 for members in &mut groups {
704 members.sort_by(|&left, &right| graph.nodes[left].id.cmp(&graph.nodes[right].id));
705 }
706 groups.sort_by(|left, right| compare_member_groups(graph, left, right));
707 let page_rank = weighted_page_rank(graph);
708 let god_nodes = detect_god_nodes(graph);
709
710 groups
711 .iter()
712 .map(|members| describe_community(graph, members, &page_rank, &god_nodes))
713 .collect()
714}
715
716fn describe_community(
717 graph: &WeightedGraph,
718 members: &[usize],
719 page_rank: &[f64],
720 god_nodes: &BTreeSet<usize>,
721) -> Community {
722 let member_set: BTreeSet<usize> = members.iter().copied().collect();
723 let central = central_nodes(graph, members, page_rank, god_nodes);
724 let (label, label_evidence) = structural_label(graph, members, ¢ral);
725 let mut repositories = BTreeSet::<RepoId>::new();
726 let mut services = BTreeSet::<NodeId>::new();
727 for &member in members {
728 if let Some(repo_id) = &graph.nodes[member].repo_id {
729 repositories.insert(repo_id.clone());
730 }
731 if graph.nodes[member].kind == NodeKind::Service {
732 services.insert(graph.nodes[member].id.clone());
733 }
734 }
735 let mut inbound_contracts = BTreeSet::new();
736 let mut outbound_contracts = BTreeSet::new();
737 let mut cohesion = 0.0;
738 let mut coupling = 0.0;
739 let mut cross_community_edges = 0;
740 let mut internal_pairs = BTreeSet::new();
741 for edge in &graph.accepted_edges {
742 let source_inside = member_set.contains(&edge.source);
743 let target_inside = member_set.contains(&edge.target);
744 if source_inside && target_inside {
745 cohesion += edge.weight;
746 if edge.source != edge.target {
747 internal_pairs.insert((edge.source, edge.target));
748 }
749 } else if source_inside || target_inside {
750 coupling += edge.weight;
751 cross_community_edges += 1;
752 if target_inside && is_contract_kind(graph.nodes[edge.target].kind) {
753 inbound_contracts.insert(graph.nodes[edge.target].id.clone());
754 }
755 if source_inside && is_contract_kind(graph.nodes[edge.source].kind) {
756 outbound_contracts.insert(graph.nodes[edge.source].id.clone());
757 }
758 }
759 }
760 let possible = members
761 .len()
762 .saturating_mul(members.len().saturating_sub(1));
763 let density = if possible == 0 {
764 0.0
765 } else {
766 usize_to_f64(internal_pairs.len()) / usize_to_f64(possible)
767 };
768 let mut limitations = Vec::new();
769 if members.len() == 1 {
770 limitations
771 .push("Community contains a single node; structural metrics are limited.".to_owned());
772 }
773 for &node in members {
774 if god_nodes.contains(&node) {
775 limitations.push(format!(
776 "High-degree god node `{}` may dominate community structure.",
777 graph.nodes[node].id.as_str()
778 ));
779 }
780 }
781 if cohesion <= EPSILON {
782 limitations.push("Community has no positive internal accepted edge weight.".to_owned());
783 }
784 limitations.sort();
785 let member_ids: Vec<NodeId> = members
786 .iter()
787 .map(|&member| graph.nodes[member].id.clone())
788 .collect();
789 let canonical_members = member_ids
790 .iter()
791 .map(|id| format!("{}:{}", id.as_str().len(), id.as_str()))
792 .collect::<Vec<_>>()
793 .join("|");
794
795 Community {
796 id: CommunityId::new(stable_id("community", &canonical_members)),
797 label,
798 members: member_ids,
799 central_nodes: central
800 .into_iter()
801 .map(|node| graph.nodes[node].id.clone())
802 .collect(),
803 repositories: repositories.into_iter().collect(),
804 services: services.into_iter().collect(),
805 inbound_contracts: inbound_contracts.into_iter().collect(),
806 outbound_contracts: outbound_contracts.into_iter().collect(),
807 metrics: CommunityMetrics {
808 size: members.len(),
809 density,
810 cohesion,
811 coupling,
812 cross_community_edges,
813 },
814 label_evidence,
815 limitations,
816 }
817}
818
819fn weighted_page_rank(graph: &WeightedGraph) -> Vec<f64> {
820 let count = graph.nodes.len();
821 if count == 0 {
822 return Vec::new();
823 }
824 let count_f64 = usize_to_f64(count);
825 let mut ranks = vec![1.0 / count_f64; count];
826 let mut outgoing = vec![0.0; count];
827 for edge in &graph.accepted_edges {
828 outgoing[edge.source] += edge.weight;
829 }
830 for _ in 0..PAGE_RANK_ITERATIONS {
831 let dangling: f64 = ranks
832 .iter()
833 .enumerate()
834 .filter(|(node, _)| outgoing[*node] <= EPSILON)
835 .map(|(_, rank)| *rank)
836 .sum();
837 let base = (1.0 - PAGE_RANK_DAMPING) / count_f64 + PAGE_RANK_DAMPING * dangling / count_f64;
838 let mut next = vec![base; count];
839 for edge in &graph.accepted_edges {
840 if outgoing[edge.source] > EPSILON {
841 next[edge.target] +=
842 PAGE_RANK_DAMPING * ranks[edge.source] * edge.weight / outgoing[edge.source];
843 }
844 }
845 ranks = next;
846 }
847 ranks
848}
849
850fn detect_god_nodes(graph: &WeightedGraph) -> BTreeSet<usize> {
851 if graph.nodes.len() < 4 {
852 return BTreeSet::new();
853 }
854 let mut degrees = graph.neighbor_count.clone();
855 degrees.sort_unstable();
856 let median = degrees[degrees.len() / 2];
857 let majority = graph.nodes.len().div_ceil(2);
858 graph
859 .neighbor_count
860 .iter()
861 .enumerate()
862 .filter(|(_, degree)| **degree >= majority && **degree >= median.saturating_mul(2).max(3))
863 .map(|(node, _)| node)
864 .collect()
865}
866
867fn central_nodes(
868 graph: &WeightedGraph,
869 members: &[usize],
870 page_rank: &[f64],
871 god_nodes: &BTreeSet<usize>,
872) -> Vec<usize> {
873 let maximum_degree = members
874 .iter()
875 .map(|&node| graph.weighted_degree[node])
876 .fold(0.0, f64::max);
877 let mut central = members.to_vec();
878 central.sort_by(|&left, &right| {
879 god_nodes
880 .contains(&right)
881 .cmp(&god_nodes.contains(&left))
882 .then_with(|| {
883 let left_score =
884 centrality_score(page_rank[left], graph.weighted_degree[left], maximum_degree);
885 let right_score = centrality_score(
886 page_rank[right],
887 graph.weighted_degree[right],
888 maximum_degree,
889 );
890 right_score.total_cmp(&left_score)
891 })
892 .then_with(|| graph.nodes[left].id.cmp(&graph.nodes[right].id))
893 });
894 central.truncate(3);
895 central
896}
897
898fn centrality_score(page_rank: f64, weighted_degree: f64, maximum_degree: f64) -> f64 {
899 let normalized_degree = if maximum_degree <= EPSILON {
900 0.0
901 } else {
902 weighted_degree / maximum_degree
903 };
904 0.65 * page_rank + 0.35 * normalized_degree
905}
906
907fn structural_label(
908 graph: &WeightedGraph,
909 members: &[usize],
910 central: &[usize],
911) -> (String, Vec<CommunityLabelEvidence>) {
912 let central_set: BTreeSet<usize> = central.iter().copied().collect();
913 let mut terms = BTreeMap::<String, (u32, NodeId, u32)>::new();
914 for &member in members {
915 let node = &graph.nodes[member];
916 let category_score = match node.kind {
917 NodeKind::Service => 400,
918 NodeKind::Repository => 300,
919 kind if is_contract_kind(kind) => 200,
920 _ => 0,
921 };
922 let central_score = if central_set.contains(&member) {
923 100
924 } else {
925 0
926 };
927 let score = category_score + central_score;
928 if score == 0 {
929 continue;
930 }
931 let mut node_terms = normalized_terms(&node.label);
932 node_terms.extend(normalized_terms(&node.stable_key));
933 for term in node_terms {
934 let entry = terms.entry(term).or_insert_with(|| (0, node.id.clone(), 0));
935 entry.0 = entry.0.saturating_add(score);
936 if score > entry.2 || (score == entry.2 && node.id < entry.1) {
937 entry.1 = node.id.clone();
938 entry.2 = score;
939 }
940 }
941 }
942 let mut ranked: Vec<(String, u32, NodeId)> = terms
943 .into_iter()
944 .map(|(term, (score, node_id, _))| (term, score, node_id))
945 .collect();
946 ranked.sort_by(|left, right| {
947 right
948 .1
949 .cmp(&left.1)
950 .then_with(|| left.0.cmp(&right.0))
951 .then_with(|| left.2.cmp(&right.2))
952 });
953 ranked.truncate(3);
954 if ranked.is_empty() {
955 let fallback = members
956 .first()
957 .map_or("community", |&member| graph.nodes[member].label.as_str());
958 return (format!("Community: {fallback}"), Vec::new());
959 }
960 let label = ranked
961 .iter()
962 .map(|(term, _, _)| title_case(term))
963 .collect::<Vec<_>>()
964 .join(" / ");
965 let evidence = ranked
966 .into_iter()
967 .map(|(term, _, node_id)| CommunityLabelEvidence { node_id, term })
968 .collect();
969 (label, evidence)
970}
971
972fn normalized_terms(value: &str) -> BTreeSet<String> {
973 value
974 .split(|character: char| !character.is_alphanumeric())
975 .map(str::to_lowercase)
976 .filter(|term| {
977 (3..=32).contains(&term.len())
978 && term.chars().any(char::is_alphabetic)
979 && !is_stop_term(term)
980 })
981 .collect()
982}
983
984fn is_stop_term(term: &str) -> bool {
985 matches!(
986 term,
987 "api"
988 | "app"
989 | "application"
990 | "artifact"
991 | "community"
992 | "default"
993 | "http"
994 | "https"
995 | "operation"
996 | "repo"
997 | "repository"
998 | "rpc"
999 | "service"
1000 | "src"
1001 | "test"
1002 | "tests"
1003 | "version"
1004 )
1005}
1006
1007fn title_case(term: &str) -> String {
1008 let mut characters = term.chars();
1009 characters.next().map_or_else(String::new, |first| {
1010 first.to_uppercase().collect::<String>() + characters.as_str()
1011 })
1012}
1013
1014fn is_contract_kind(kind: NodeKind) -> bool {
1015 matches!(
1016 kind,
1017 NodeKind::HttpOperation
1018 | NodeKind::GraphqlOperation
1019 | NodeKind::RpcMethod
1020 | NodeKind::EventChannel
1021 | NodeKind::EventSchema
1022 | NodeKind::Database
1023 | NodeKind::DatabaseTable
1024 )
1025}
1026
1027fn compare_member_groups(graph: &WeightedGraph, left: &[usize], right: &[usize]) -> Ordering {
1028 left.iter()
1029 .map(|&node| graph.nodes[node].id.as_str())
1030 .cmp(right.iter().map(|&node| graph.nodes[node].id.as_str()))
1031}
1032
1033fn overlap_matrix(before: &[Community], after: &[Community]) -> BTreeMap<(usize, usize), f64> {
1034 let mut overlaps = BTreeMap::new();
1035 for (before_index, old) in before.iter().enumerate() {
1036 let old_members: BTreeSet<&str> = old.members.iter().map(NodeId::as_str).collect();
1037 for (after_index, new) in after.iter().enumerate() {
1038 let new_members: BTreeSet<&str> = new.members.iter().map(NodeId::as_str).collect();
1039 let intersection = old_members.intersection(&new_members).count();
1040 let union = old_members.union(&new_members).count();
1041 let overlap = if union == 0 {
1042 1.0
1043 } else {
1044 usize_to_f64(intersection) / usize_to_f64(union)
1045 };
1046 overlaps.insert((before_index, after_index), overlap);
1047 }
1048 }
1049 overlaps
1050}
1051
1052fn related_after(
1053 overlaps: &BTreeMap<(usize, usize), f64>,
1054 before_count: usize,
1055 after_count: usize,
1056) -> Vec<Vec<usize>> {
1057 (0..before_count)
1058 .map(|before| {
1059 (0..after_count)
1060 .filter(|after| {
1061 overlaps
1062 .get(&(before, *after))
1063 .is_some_and(|overlap| *overlap >= RELATED_OVERLAP)
1064 })
1065 .collect()
1066 })
1067 .collect()
1068}
1069
1070fn related_before(
1071 overlaps: &BTreeMap<(usize, usize), f64>,
1072 before_count: usize,
1073 after_count: usize,
1074) -> Vec<Vec<usize>> {
1075 (0..after_count)
1076 .map(|after| {
1077 (0..before_count)
1078 .filter(|before| {
1079 overlaps
1080 .get(&(*before, after))
1081 .is_some_and(|overlap| *overlap >= RELATED_OVERLAP)
1082 })
1083 .collect()
1084 })
1085 .collect()
1086}
1087
1088fn compare_changes(left: &CommunityChange, right: &CommunityChange) -> Ordering {
1089 change_kind_index(left.kind)
1090 .cmp(&change_kind_index(right.kind))
1091 .then_with(|| left.before.cmp(&right.before))
1092 .then_with(|| left.after.cmp(&right.after))
1093 .then_with(|| left.overlap.total_cmp(&right.overlap))
1094 .then_with(|| left.explanation.cmp(&right.explanation))
1095}
1096
1097const fn change_kind_index(kind: CommunityChangeKind) -> usize {
1098 match kind {
1099 CommunityChangeKind::Created => 0,
1100 CommunityChangeKind::Removed => 1,
1101 CommunityChangeKind::Split => 2,
1102 CommunityChangeKind::Merged => 3,
1103 CommunityChangeKind::MateriallyChanged => 4,
1104 }
1105}
1106
1107const fn edge_kind_index(kind: EdgeKind) -> usize {
1108 match kind {
1109 EdgeKind::Contains => 0,
1110 EdgeKind::Provides => 1,
1111 EdgeKind::Consumes => 2,
1112 EdgeKind::CallsRemote => 3,
1113 EdgeKind::Publishes => 4,
1114 EdgeKind::Subscribes => 5,
1115 EdgeKind::DeliversTo => 6,
1116 EdgeKind::DependsOnPackage => 7,
1117 EdgeKind::DependsOnRepository => 8,
1118 EdgeKind::ReadsTable => 9,
1119 EdgeKind::WritesTable => 10,
1120 EdgeKind::Deploys => 11,
1121 EdgeKind::Configures => 12,
1122 EdgeKind::Documents => 13,
1123 EdgeKind::OwnedBy => 14,
1124 EdgeKind::ImplementedBy => 15,
1125 EdgeKind::Validates => 16,
1126 EdgeKind::ChangedIn => 17,
1127 EdgeKind::Affects => 18,
1128 EdgeKind::Precedes => 19,
1129 EdgeKind::Reverts => 20,
1130 EdgeKind::CompatibleWith => 21,
1131 EdgeKind::IncompatibleWith => 22,
1132 EdgeKind::MemberOf => 23,
1133 EdgeKind::ManualLink => 24,
1134 }
1135}
1136
1137#[expect(
1138 clippy::cast_precision_loss,
1139 reason = "graph cardinalities are bounded by addressable memory and only form normalized metrics"
1140)]
1141fn usize_to_f64(value: usize) -> f64 {
1142 value as f64
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use code_system_graph_model::{EdgeId, NodeId};
1148
1149 use super::*;
1150
1151 fn node(id: &str, kind: NodeKind, repo: &str, label: &str) -> Node {
1152 Node {
1153 id: NodeId::new(id),
1154 kind,
1155 repo_id: Some(RepoId::new(repo)),
1156 stable_key: format!("stable:{label}"),
1157 label: label.to_owned(),
1158 }
1159 }
1160
1161 fn edge(id: &str, source: &str, target: &str, kind: EdgeKind, confidence: f32) -> Edge {
1162 Edge {
1163 id: EdgeId::new(id),
1164 source: NodeId::new(source),
1165 target: NodeId::new(target),
1166 kind,
1167 confidence,
1168 status: EpistemicStatus::Confirmed,
1169 evidence: Vec::new(),
1170 }
1171 }
1172
1173 fn config(algorithm: CommunityAlgorithm) -> CommunityConfig {
1174 CommunityConfig {
1175 algorithm,
1176 scope: CommunityScope::Federated,
1177 seed: 17,
1178 resolution: 1.0,
1179 minimum_confidence: 0.0,
1180 edge_weights: Vec::new(),
1181 max_iterations: 100,
1182 }
1183 }
1184
1185 fn two_cluster_graph() -> (Vec<Node>, Vec<Edge>) {
1186 let nodes = ["a", "b", "c", "d", "e", "f"]
1187 .into_iter()
1188 .map(|id| node(id, NodeKind::Service, "repo:one", id))
1189 .collect();
1190 let mut edges = Vec::new();
1191 let mut number = 0;
1192 for cluster in [["a", "b", "c"], ["d", "e", "f"]] {
1193 for source in cluster {
1194 for target in cluster {
1195 if source != target {
1196 edges.push(edge(
1197 &format!("e{number}"),
1198 source,
1199 target,
1200 EdgeKind::CallsRemote,
1201 1.0,
1202 ));
1203 number += 1;
1204 }
1205 }
1206 }
1207 }
1208 edges.push(edge("bridge", "c", "d", EdgeKind::ManualLink, 1.0));
1209 (nodes, edges)
1210 }
1211
1212 #[test]
1213 fn connected_components_should_return_maximal_regions() {
1214 let nodes = vec![
1215 node("a", NodeKind::Service, "repo:one", "alpha"),
1216 node("b", NodeKind::HttpOperation, "repo:one", "alpha endpoint"),
1217 node("c", NodeKind::Service, "repo:two", "beta"),
1218 ];
1219 let edges = vec![edge("ab", "a", "b", EdgeKind::Provides, 1.0)];
1220
1221 let result = analyze_communities(
1222 "snapshot",
1223 &nodes,
1224 &edges,
1225 config(CommunityAlgorithm::ConnectedComponents),
1226 )
1227 .expect("analysis should succeed");
1228
1229 assert_eq!(
1230 result
1231 .communities
1232 .iter()
1233 .map(|community| community.metrics.size)
1234 .collect::<Vec<_>>(),
1235 vec![2, 1]
1236 );
1237 }
1238
1239 #[test]
1240 fn weighted_clustering_should_respect_relationship_weights() {
1241 let (nodes, edges) = two_cluster_graph();
1242 let mut analysis_config = config(CommunityAlgorithm::WeightedClustering);
1243 analysis_config.edge_weights.push(CommunityEdgeWeight {
1244 kind: EdgeKind::ManualLink,
1245 weight: 0.01,
1246 });
1247
1248 let result = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1249 .expect("analysis should succeed");
1250
1251 assert_eq!(result.communities.len(), 2);
1252 }
1253
1254 #[test]
1255 fn louvain_should_optimize_modularity_into_weighted_clusters() {
1256 let (nodes, edges) = two_cluster_graph();
1257 let mut analysis_config = config(CommunityAlgorithm::Louvain);
1258 analysis_config.edge_weights.push(CommunityEdgeWeight {
1259 kind: EdgeKind::ManualLink,
1260 weight: 0.05,
1261 });
1262
1263 let result = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1264 .expect("analysis should succeed");
1265
1266 assert_eq!(result.communities.len(), 2);
1267 }
1268
1269 #[test]
1270 fn seeded_analysis_should_be_byte_reproducible() {
1271 let (mut nodes, mut edges) = two_cluster_graph();
1272 let analysis_config = config(CommunityAlgorithm::Louvain);
1273 let first = analyze_communities("snapshot", &nodes, &edges, analysis_config.clone())
1274 .expect("first analysis should succeed");
1275 nodes.reverse();
1276 edges.reverse();
1277 let second = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1278 .expect("second analysis should succeed");
1279
1280 assert_eq!(
1281 serde_json::to_vec(&first).expect("snapshot should serialize"),
1282 serde_json::to_vec(&second).expect("snapshot should serialize")
1283 );
1284 }
1285
1286 #[test]
1287 fn repository_scope_should_exclude_other_repositories() {
1288 let nodes = vec![
1289 node("a", NodeKind::Service, "repo:one", "alpha"),
1290 node("b", NodeKind::Artifact, "repo:one", "alpha manifest"),
1291 node("c", NodeKind::Service, "repo:two", "beta"),
1292 ];
1293 let edges = vec![
1294 edge("ab", "a", "b", EdgeKind::Contains, 1.0),
1295 edge("ac", "a", "c", EdgeKind::CallsRemote, 1.0),
1296 ];
1297 let mut analysis_config = config(CommunityAlgorithm::ConnectedComponents);
1298 analysis_config.scope = CommunityScope::Repository(RepoId::new("repo:one"));
1299
1300 let result = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1301 .expect("analysis should succeed");
1302
1303 assert_eq!(result.communities[0].members.len(), 2);
1304 }
1305
1306 #[test]
1307 fn service_scope_should_include_only_directly_attached_nodes() {
1308 let mut service = node("service", NodeKind::Service, "repo:one", "billing");
1309 service.stable_key = "service:billing".to_owned();
1310 let nodes = vec![
1311 service,
1312 node("contract", NodeKind::HttpOperation, "repo:one", "charge"),
1313 node("indirect", NodeKind::Artifact, "repo:one", "schema"),
1314 ];
1315 let edges = vec![
1316 edge("first", "service", "contract", EdgeKind::Provides, 1.0),
1317 edge("second", "contract", "indirect", EdgeKind::Contains, 1.0),
1318 ];
1319 let mut analysis_config = config(CommunityAlgorithm::ConnectedComponents);
1320 analysis_config.scope = CommunityScope::Service("service:billing".to_owned());
1321
1322 let result = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1323 .expect("analysis should succeed");
1324
1325 assert_eq!(
1326 result.communities[0].members,
1327 vec![NodeId::new("contract"), NodeId::new("service")]
1328 );
1329 }
1330
1331 #[test]
1332 fn metrics_should_report_cohesion_density_and_cross_coupling() {
1333 let (nodes, edges) = two_cluster_graph();
1334 let mut analysis_config = config(CommunityAlgorithm::Louvain);
1335 analysis_config.edge_weights.push(CommunityEdgeWeight {
1336 kind: EdgeKind::ManualLink,
1337 weight: 0.05,
1338 });
1339
1340 let result = analyze_communities("snapshot", &nodes, &edges, analysis_config)
1341 .expect("analysis should succeed");
1342 let first = &result.communities[0];
1343
1344 assert!(
1345 (first.metrics.density - 1.0).abs() <= EPSILON
1346 && first.metrics.cohesion > first.metrics.coupling
1347 && first.metrics.cross_community_edges == 1
1348 );
1349 }
1350
1351 #[test]
1352 fn labels_should_prefer_service_and_contract_terms_with_evidence() {
1353 let nodes = vec![
1354 node("service", NodeKind::Service, "repo:one", "Billing Gateway"),
1355 node(
1356 "contract",
1357 NodeKind::HttpOperation,
1358 "repo:one",
1359 "Create Invoice",
1360 ),
1361 ];
1362 let edges = vec![edge(
1363 "provides",
1364 "service",
1365 "contract",
1366 EdgeKind::Provides,
1367 1.0,
1368 )];
1369
1370 let result = analyze_communities(
1371 "snapshot",
1372 &nodes,
1373 &edges,
1374 config(CommunityAlgorithm::ConnectedComponents),
1375 )
1376 .expect("analysis should succeed");
1377
1378 assert!(
1379 result.communities[0].label.contains("Billing")
1380 && !result.communities[0].label_evidence.is_empty()
1381 );
1382 }
1383
1384 #[test]
1385 fn god_nodes_should_be_central_and_disclosed() {
1386 let mut nodes = vec![node("hub", NodeKind::Service, "repo:one", "gateway")];
1387 let mut edges = Vec::new();
1388 for index in 0..5 {
1389 let leaf = format!("leaf-{index}");
1390 nodes.push(node(&leaf, NodeKind::Artifact, "repo:one", &leaf));
1391 edges.push(edge(
1392 &format!("edge-{index}"),
1393 "hub",
1394 &leaf,
1395 EdgeKind::Contains,
1396 1.0,
1397 ));
1398 }
1399
1400 let result = analyze_communities(
1401 "snapshot",
1402 &nodes,
1403 &edges,
1404 config(CommunityAlgorithm::ConnectedComponents),
1405 )
1406 .expect("analysis should succeed");
1407
1408 assert!(
1409 result.communities[0].central_nodes[0] == NodeId::new("hub")
1410 && result.communities[0]
1411 .limitations
1412 .iter()
1413 .any(|limitation| limitation.contains("god node"))
1414 );
1415 }
1416
1417 fn community(id: &str, members: &[&str]) -> Community {
1418 Community {
1419 id: CommunityId::new(id),
1420 label: id.to_owned(),
1421 members: members.iter().map(|member| NodeId::new(*member)).collect(),
1422 central_nodes: Vec::new(),
1423 repositories: Vec::new(),
1424 services: Vec::new(),
1425 inbound_contracts: Vec::new(),
1426 outbound_contracts: Vec::new(),
1427 metrics: CommunityMetrics {
1428 size: members.len(),
1429 density: 0.0,
1430 cohesion: 0.0,
1431 coupling: 0.0,
1432 cross_community_edges: 0,
1433 },
1434 label_evidence: Vec::new(),
1435 limitations: Vec::new(),
1436 }
1437 }
1438
1439 fn snapshot(id: &str, communities: Vec<Community>) -> CommunitySnapshot {
1440 CommunitySnapshot {
1441 snapshot_id: id.to_owned(),
1442 engine_version: ENGINE_VERSION.to_owned(),
1443 config: config(CommunityAlgorithm::ConnectedComponents),
1444 communities,
1445 }
1446 }
1447
1448 #[test]
1449 fn comparison_should_classify_created_community() {
1450 let before = snapshot("before", Vec::new());
1451 let after = snapshot("after", vec![community("new", &["a"])]);
1452
1453 let delta = compare_community_snapshots(&before, &after);
1454
1455 assert_eq!(delta.changes[0].kind, CommunityChangeKind::Created);
1456 }
1457
1458 #[test]
1459 fn comparison_should_classify_removed_community() {
1460 let before = snapshot("before", vec![community("old", &["a"])]);
1461 let after = snapshot("after", Vec::new());
1462
1463 let delta = compare_community_snapshots(&before, &after);
1464
1465 assert_eq!(delta.changes[0].kind, CommunityChangeKind::Removed);
1466 }
1467
1468 #[test]
1469 fn comparison_should_classify_split_community() {
1470 let before = snapshot("before", vec![community("old", &["a", "b", "c", "d"])]);
1471 let after = snapshot(
1472 "after",
1473 vec![
1474 community("left", &["a", "b"]),
1475 community("right", &["c", "d"]),
1476 ],
1477 );
1478
1479 let delta = compare_community_snapshots(&before, &after);
1480
1481 assert_eq!(delta.changes[0].kind, CommunityChangeKind::Split);
1482 }
1483
1484 #[test]
1485 fn comparison_should_classify_merged_community() {
1486 let before = snapshot(
1487 "before",
1488 vec![
1489 community("left", &["a", "b"]),
1490 community("right", &["c", "d"]),
1491 ],
1492 );
1493 let after = snapshot("after", vec![community("new", &["a", "b", "c", "d"])]);
1494
1495 let delta = compare_community_snapshots(&before, &after);
1496
1497 assert_eq!(delta.changes[0].kind, CommunityChangeKind::Merged);
1498 }
1499
1500 #[test]
1501 fn comparison_should_classify_materially_changed_community() {
1502 let before = snapshot("before", vec![community("old", &["a", "b", "c", "d"])]);
1503 let after = snapshot("after", vec![community("new", &["a", "b", "c", "e"])]);
1504
1505 let delta = compare_community_snapshots(&before, &after);
1506
1507 assert_eq!(
1508 delta.changes[0].kind,
1509 CommunityChangeKind::MateriallyChanged
1510 );
1511 }
1512
1513 #[test]
1514 fn validation_should_reject_non_finite_confidence() {
1515 let nodes = vec![
1516 node("a", NodeKind::Service, "repo:one", "alpha"),
1517 node("b", NodeKind::Service, "repo:one", "beta"),
1518 ];
1519 let edges = vec![edge("invalid", "a", "b", EdgeKind::CallsRemote, f32::NAN)];
1520
1521 let error = analyze_communities(
1522 "snapshot",
1523 &nodes,
1524 &edges,
1525 config(CommunityAlgorithm::ConnectedComponents),
1526 )
1527 .expect_err("invalid confidence should fail");
1528
1529 assert!(matches!(
1530 error,
1531 CommunityError::InvalidEdgeConfidence { .. }
1532 ));
1533 }
1534
1535 #[test]
1536 fn completed_community_units_should_report_progress() {
1537 let (nodes, edges) = two_cluster_graph();
1538 let mut completed = 0_u64;
1539
1540 analyze_communities_with_progress(
1541 "snapshot",
1542 &nodes,
1543 &edges,
1544 config(CommunityAlgorithm::Louvain),
1545 |units| completed = completed.checked_add(units).expect("bounded progress"),
1546 )
1547 .expect("analysis");
1548
1549 assert!(completed > 0);
1550 }
1551}