Skip to main content

code_system_graph_core/
communities.rs

1//! Deterministic community detection and comparison for federated graphs.
2
3use 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/// Failure returned when community-analysis inputs violate their documented invariants.
20#[derive(Debug, Error, PartialEq)]
21pub enum CommunityError {
22    /// The Louvain resolution is not finite and strictly positive.
23    #[error("community resolution must be finite and positive, found {0}")]
24    InvalidResolution(f64),
25    /// The iteration limit is outside the supported inclusive range.
26    #[error("community max_iterations must be in 1..=10_000, found {0}")]
27    InvalidMaxIterations(u32),
28    /// The configured confidence threshold is not finite or outside zero through one.
29    #[error("community minimum_confidence must be finite and in 0..=1, found {0}")]
30    InvalidMinimumConfidence(f32),
31    /// An input edge has a non-finite or out-of-range confidence.
32    #[error("edge `{edge_id}` confidence must be finite and in 0..=1, found {confidence}")]
33    InvalidEdgeConfidence {
34        /// Stable identity of the invalid edge.
35        edge_id: String,
36        /// Invalid confidence value.
37        confidence: f32,
38    },
39    /// A configured relationship weight is negative or non-finite.
40    #[error("community edge weight for `{kind:?}` must be finite and non-negative, found {weight}")]
41    InvalidEdgeWeight {
42        /// Relationship kind carrying the invalid weight.
43        kind: EdgeKind,
44        /// Invalid relationship weight.
45        weight: f64,
46    },
47    /// More than one explicit weight was supplied for the same relationship kind.
48    #[error("community edge weight for `{0:?}` is configured more than once")]
49    DuplicateEdgeWeight(EdgeKind),
50    /// More than one node uses the same stable identifier.
51    #[error("node identifier `{0}` occurs more than once")]
52    DuplicateNodeId(String),
53    /// Service scope did not resolve to an exact service stable key.
54    #[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
76/// Detects deterministic communities in one immutable graph snapshot.
77///
78/// Only confirmed edges meeting `minimum_confidence` participate. Relationship
79/// weights are multiplied by edge confidence, parallel directions are summed,
80/// and the resulting weighted graph is symmetrized before clustering. Central
81/// nodes combine weighted `PageRank` (65%) and normalized weighted degree (35%),
82/// with detected high-degree god nodes disclosed and ranked first.
83///
84/// # Errors
85///
86/// Returns [`CommunityError`] when configuration values, edge confidences, node
87/// identities, or an exact service scope are invalid.
88pub 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
97/// Analyzes communities while reporting each completed finite optimization unit.
98///
99/// # Errors
100///
101/// Returns [`CommunityError`] under the same conditions as [`analyze_communities`].
102pub 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/// Compares two community snapshots using deterministic member-set overlap.
139///
140/// Jaccard overlap of at least 0.25 establishes a material relationship. One
141/// old community related to multiple new communities is a split, while the
142/// inverse is a merge. Remaining one-to-one relationships below 0.80 are
143/// materially changed; unrelated communities are created or removed.
144#[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(&current).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
640/// Computes generalized undirected modularity:
641/// `sum_c(internal_weight_c / m - resolution * (degree_c / (2m))^2)`.
642fn 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, &central);
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}