Skip to main content

pedant_graph/analysis/
divergence.rs

1//! Where the declared module tree and the selected topology disagree.
2//!
3//! Every answer here is evidence rather than judgment: how much of a partition's
4//! traffic stays inside it, how far the whole partition sits from what the
5//! selected edges suggest, which symbols call somewhere else, and which cycles
6//! run through more than one partition. No threshold is applied, no verdict is
7//! stated, and no other partition is searched for.
8//!
9//! The partition is derived once, during analysis construction. The counts come
10//! from the same shared adjacency lists every other query reads, the degrees
11//! from the one module that measures them, and the cycles from the one module
12//! that discovers them.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use crate::graph::CodeGraph;
17use crate::id::{GraphNodeId, position};
18use crate::node::GraphNodeKind;
19
20use super::components::{self, GraphComponent, GraphComponentId, GraphComponents};
21use super::degree::{self, DegreeCentrality};
22use super::index::{AnalysisContext, SelectedAdjacency, SelectedIndexes};
23use super::partition::DeclaredModulePartition;
24
25/// How much of one partition's outgoing traffic stays inside it.
26#[derive(Clone, Copy, Debug)]
27pub struct ModuleCohesion {
28    root: GraphNodeId,
29    internal: u32,
30    boundary: u32,
31    score: Option<f64>,
32}
33
34impl ModuleCohesion {
35    /// The partition root these counts describe.
36    pub fn root(self) -> GraphNodeId {
37        self.root
38    }
39
40    /// How many selected edges leave a member for another member.
41    pub fn internal_edges(self) -> u32 {
42        self.internal
43    }
44
45    /// How many selected edges leave a member for another partition.
46    pub fn boundary_edges(self) -> u32 {
47        self.boundary
48    }
49
50    /// The internal share of both counts, or nothing at all when no selected
51    /// edge leaves this partition.
52    pub fn score(self) -> Option<f64> {
53        self.score
54    }
55}
56
57/// One symbol's selected edges into one partition other than its own.
58///
59/// Evidence, not a verdict: the record states how many of the symbol's selected
60/// edges reach that partition and how many leave it at all, and leaves what to
61/// do about it to a consumer that owns a policy.
62///
63/// Which relations count is the caller's selection to state, so every edge the
64/// selection admitted is counted rather than calls alone.
65#[derive(Clone, Copy, Debug)]
66pub struct MisplacementCandidate {
67    symbol: GraphNodeId,
68    declared: GraphNodeId,
69    candidate: GraphNodeId,
70    foreign: u32,
71    total: u32,
72    affinity: f64,
73}
74
75impl MisplacementCandidate {
76    /// The symbol whose selected edges this record counts.
77    pub fn symbol(self) -> GraphNodeId {
78        self.symbol
79    }
80
81    /// The partition that declares it.
82    pub fn declared_partition(self) -> GraphNodeId {
83        self.declared
84    }
85
86    /// The other partition its selected edges reach.
87    pub fn candidate_partition(self) -> GraphNodeId {
88        self.candidate
89    }
90
91    /// How many of its selected edges reach that partition.
92    pub fn foreign_edges(self) -> u32 {
93        self.foreign
94    }
95
96    /// How many selected edges leave it at all.
97    pub fn total_outgoing_edges(self) -> u32 {
98        self.total
99    }
100
101    /// The share of its selected edges that partition takes.
102    pub fn affinity(self) -> f64 {
103        self.affinity
104    }
105}
106
107/// One cycle whose members are declared in more than one partition.
108#[derive(Debug)]
109pub struct BoundaryCrossingComponent {
110    component: GraphComponentId,
111    partitions: Box<[GraphNodeId]>,
112}
113
114impl BoundaryCrossingComponent {
115    /// The component this evidence names.
116    pub fn component(&self) -> GraphComponentId {
117        self.component
118    }
119
120    /// Every partition its members are declared in, in root-id order.
121    pub fn partitions(&self) -> &[GraphNodeId] {
122        &self.partitions
123    }
124}
125
126/// What one selected topology says about the partition its graph declares.
127#[derive(Debug)]
128pub struct GraphDivergence {
129    cohesion: Box<[ModuleCohesion]>,
130    modularity: Option<f64>,
131    candidates: Box<[MisplacementCandidate]>,
132    boundary: Box<[BoundaryCrossingComponent]>,
133}
134
135impl GraphDivergence {
136    /// Every partition's cohesion, in root-id order.
137    pub fn cohesion(&self) -> &[ModuleCohesion] {
138        &self.cohesion
139    }
140
141    /// The directed Newman modularity of the declared partition, or nothing at
142    /// all when the selection admits no edge.
143    pub fn modularity(&self) -> Option<f64> {
144        self.modularity
145    }
146
147    /// Every symbol's foreign-partition calls, by symbol then candidate.
148    pub fn candidates(&self) -> &[MisplacementCandidate] {
149        &self.candidates
150    }
151
152    /// Every cycle that runs through more than one partition, in component
153    /// order.
154    pub fn boundary_components(&self) -> &[BoundaryCrossingComponent] {
155        &self.boundary
156    }
157}
158
159/// What the selected topology says about the declared partition.
160///
161/// Infallible for an admitted analysis: every count is bounded by the admitted
162/// edge count, and the modularity converts those bounded counts to `f64` before
163/// it multiplies them.
164pub(crate) fn divergence(analysis: &AnalysisContext<'_>) -> GraphDivergence {
165    let indexes = analysis.indexes();
166    let partition = analysis.declared_partition();
167    let cohesion = measured(indexes, partition);
168    let summed = totals(&degree::degree(analysis), partition);
169    GraphDivergence {
170        modularity: modularity(&cohesion, &summed, indexes.selected_edges()),
171        candidates: candidates(analysis.graph(), indexes, partition),
172        boundary: crossings(&components::discover(analysis), partition),
173        cohesion,
174    }
175}
176
177/// Each partition's internal and boundary counts, in root-id order.
178fn measured(
179    indexes: &SelectedIndexes,
180    partition: &DeclaredModulePartition,
181) -> Box<[ModuleCohesion]> {
182    let mut counted: BTreeMap<GraphNodeId, (u32, u32)> = empty_counts(partition.roots());
183    for (at, owner) in partition.owners().iter().enumerate() {
184        let node = GraphNodeId::new(position(at));
185        if let Some(pair) = counted.get_mut(owner) {
186            record(pair, *owner, indexes.outgoing(node), partition);
187        }
188    }
189    counted.into_iter().map(stated_cohesion).collect()
190}
191
192/// One zeroed count per partition root.
193fn empty_counts(roots: &[GraphNodeId]) -> BTreeMap<GraphNodeId, (u32, u32)> {
194    roots.iter().map(|root| (*root, (0, 0))).collect()
195}
196
197/// Count one node's selected edges inside its own partition, or leaving it.
198///
199/// The partition's counter is resolved once per node rather than once per edge,
200/// because every edge leaving one node leaves the same partition.
201fn record(
202    counted: &mut (u32, u32),
203    owner: GraphNodeId,
204    leaving: &[SelectedAdjacency],
205    partition: &DeclaredModulePartition,
206) {
207    for entry in leaving {
208        match partition.owner(entry.node()) == Some(owner) {
209            true => counted.0 += 1,
210            false => counted.1 += 1,
211        }
212    }
213}
214
215/// One partition's counts as its cohesion record.
216fn stated_cohesion(counted: (GraphNodeId, (u32, u32))) -> ModuleCohesion {
217    let (root, (internal, boundary)) = counted;
218    ModuleCohesion {
219        root,
220        internal,
221        boundary,
222        score: share(internal, internal + boundary),
223    }
224}
225
226/// One share of a total, or nothing at all when there is no total.
227fn share(part: u32, total: u32) -> Option<f64> {
228    match total {
229        0 => None,
230        _ => Some(f64::from(part) / f64::from(total)),
231    }
232}
233
234/// Each partition's total outgoing and incoming selected degree, in root-id
235/// order.
236///
237/// Both are summed as `f64` before anything multiplies them, so a partition of a
238/// repository-sized graph cannot overflow the product the modularity takes.
239fn totals(degrees: &[DegreeCentrality], partition: &DeclaredModulePartition) -> Box<[(f64, f64)]> {
240    let mut summed: BTreeMap<GraphNodeId, (f64, f64)> = partition
241        .roots()
242        .iter()
243        .map(|root| (*root, (0.0, 0.0)))
244        .collect();
245    for (owner, degree) in partition.owners().iter().zip(degrees) {
246        accumulate(&mut summed, *owner, *degree);
247    }
248    summed.into_values().collect()
249}
250
251/// Add one node's degrees to the partition that declares it.
252fn accumulate(
253    summed: &mut BTreeMap<GraphNodeId, (f64, f64)>,
254    owner: GraphNodeId,
255    degree: DegreeCentrality,
256) {
257    if let Some(pair) = summed.get_mut(&owner) {
258        pair.0 += f64::from(degree.outgoing());
259        pair.1 += f64::from(degree.incoming());
260    }
261}
262
263/// The directed modularity of the declared partition, or nothing at all when no
264/// edge was selected.
265fn modularity(cohesion: &[ModuleCohesion], summed: &[(f64, f64)], edges: u32) -> Option<f64> {
266    match edges {
267        0 => None,
268        _ => Some(newman(cohesion, summed, f64::from(edges))),
269    }
270}
271
272/// `Q = (1 / m) * sum(A_ij - k_out(i) * k_in(j) / m)` over same-partition pairs.
273///
274/// Every same-partition pair with an edge between it is counted by that
275/// partition's internal count, and the expected term factors into one product
276/// per partition, so the sum is taken over the partitions rather than over every
277/// pair of nodes.
278fn newman(cohesion: &[ModuleCohesion], summed: &[(f64, f64)], edges: f64) -> f64 {
279    let internal: f64 = cohesion
280        .iter()
281        .map(|stated| f64::from(stated.internal))
282        .sum();
283    let expected: f64 = summed
284        .iter()
285        .map(|(leaving, arriving)| leaving * arriving)
286        .sum();
287    internal / edges - expected / (edges * edges)
288}
289
290/// Every symbol's foreign-partition calls, by symbol then candidate partition.
291fn candidates(
292    graph: &CodeGraph,
293    indexes: &SelectedIndexes,
294    partition: &DeclaredModulePartition,
295) -> Box<[MisplacementCandidate]> {
296    graph
297        .nodes()
298        .iter()
299        .filter(|node| is_symbol(node.kind()))
300        .flat_map(|node| foreign(indexes, partition, node.id()))
301        .collect()
302}
303
304/// Whether one node is a symbol an affinity record may name.
305///
306/// A file, a unit root, or a module is where symbols are declared rather than
307/// something that could be declared somewhere else. The match names every kind
308/// the vocabulary states, so a kind added to it has to be classified here rather
309/// than silently falling out of every affinity answer.
310fn is_symbol(kind: &GraphNodeKind) -> bool {
311    match kind {
312        GraphNodeKind::Function { .. }
313        | GraphNodeKind::Type { .. }
314        | GraphNodeKind::Value { .. } => true,
315        GraphNodeKind::File | GraphNodeKind::Container { .. } => false,
316    }
317}
318
319/// One symbol's records, one per partition its selected calls reach beyond its
320/// own.
321fn foreign<'a>(
322    indexes: &'a SelectedIndexes,
323    partition: &'a DeclaredModulePartition,
324    symbol: GraphNodeId,
325) -> impl Iterator<Item = MisplacementCandidate> + 'a {
326    partition
327        .owner(symbol)
328        .into_iter()
329        .flat_map(move |declared| reaching(indexes, partition, (symbol, declared)))
330}
331
332/// One declared symbol's records, one per partition its selected calls reach
333/// beyond its own.
334///
335/// The count of calls it makes at all is read once, before the records that
336/// divide by it.
337fn reaching(
338    indexes: &SelectedIndexes,
339    partition: &DeclaredModulePartition,
340    declaration: (GraphNodeId, GraphNodeId),
341) -> impl Iterator<Item = MisplacementCandidate> {
342    let (symbol, declared) = declaration;
343    let leaving = indexes.outgoing(symbol);
344    let total = position(leaving.len());
345    let mut reached: BTreeMap<GraphNodeId, u32> = BTreeMap::new();
346    for entry in leaving {
347        claim(&mut reached, partition.owner(entry.node()), declared);
348    }
349    reached
350        .into_iter()
351        .map(move |found| stated_candidate((symbol, declared), found, total))
352}
353
354/// Count one selected edge that leaves its symbol's own partition.
355fn claim(
356    reached: &mut BTreeMap<GraphNodeId, u32>,
357    owner: Option<GraphNodeId>,
358    declared: GraphNodeId,
359) {
360    match owner {
361        Some(found) if found != declared => *reached.entry(found).or_insert(0) += 1,
362        _ => (),
363    }
364}
365
366/// One symbol's calls into one other partition, as its affinity record.
367fn stated_candidate(
368    declaration: (GraphNodeId, GraphNodeId),
369    reached: (GraphNodeId, u32),
370    total: u32,
371) -> MisplacementCandidate {
372    let (symbol, declared) = declaration;
373    let (candidate, foreign) = reached;
374    MisplacementCandidate {
375        symbol,
376        declared,
377        candidate,
378        foreign,
379        total,
380        affinity: f64::from(foreign) / f64::from(total),
381    }
382}
383
384/// Every component whose members are declared in more than one partition.
385fn crossings(
386    components: &GraphComponents,
387    partition: &DeclaredModulePartition,
388) -> Box<[BoundaryCrossingComponent]> {
389    components
390        .components()
391        .iter()
392        .filter_map(|component| crossing(component, partition))
393        .collect()
394}
395
396/// One component's evidence, unless every member is declared in one partition.
397fn crossing(
398    component: &GraphComponent,
399    partition: &DeclaredModulePartition,
400) -> Option<BoundaryCrossingComponent> {
401    let partitions: BTreeSet<GraphNodeId> = component
402        .members()
403        .iter()
404        .filter_map(|member| partition.owner(*member))
405        .collect();
406    match partitions.len() > 1 {
407        false => None,
408        true => Some(BoundaryCrossingComponent {
409            component: component.id(),
410            partitions: partitions.into_iter().collect(),
411        }),
412    }
413}