Skip to main content

drft/
impact.rs

1//! `drft impact`: list the nodes transitively affected by a change to the seed
2//! nodes, as a flat, instruction-bearing list sorted by review priority. The
3//! ranking metrics (`impact_radius`, `betweenness`) are computed inline over the
4//! composed graph — there is no separate analyses layer.
5//!
6//! Traversal is cycle-safe (a visited set), so a dependency cycle resolves
7//! without looping.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10
11use crate::model::Graph;
12
13/// Adjacency: a path mapped to the paths it connects to.
14type Adjacency<'a> = HashMap<&'a str, Vec<&'a str>>;
15
16/// Which way to walk from the seed. Inbound (the default) finds dependents —
17/// what must be reviewed when the seed changes.
18#[derive(Clone, Copy, PartialEq, Eq)]
19pub enum Direction {
20    Inbound,
21    Outbound,
22    Both,
23}
24
25/// One impacted node with its causal chain and ranking metrics.
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct Impacted {
28    pub node: String,
29    /// The neighbor through which this node was reached.
30    pub via: String,
31    pub depth: usize,
32    /// Count of nodes that transitively depend on this node.
33    pub impact_radius: usize,
34    /// Betweenness centrality of this node in the whole graph.
35    pub betweenness: f64,
36    pub fix: String,
37}
38
39/// Compute the impacted set for `seeds`. Seeds must be node paths present in
40/// `graph`; unknown seeds are skipped.
41pub fn compute(
42    graph: &Graph,
43    seeds: &[String],
44    direction: Direction,
45    max_depth: Option<usize>,
46) -> Vec<Impacted> {
47    let (forward, reverse) = adjacency(graph);
48    let betweenness = betweenness(graph);
49
50    let mut visited: HashSet<&str> = HashSet::new();
51    let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
52
53    for seed in seeds {
54        if let Some((key, _)) = graph.nodes.get_key_value(seed) {
55            let seed = key.as_str();
56            if visited.insert(seed) {
57                queue.push_back((seed, 0));
58            }
59        }
60    }
61
62    // (node, via, depth)
63    let mut reached: Vec<(&str, &str, usize)> = Vec::new();
64
65    while let Some((node, depth)) = queue.pop_front() {
66        if max_depth.is_some_and(|max| depth >= max) {
67            continue;
68        }
69        let mut neighbors: Vec<&str> = Vec::new();
70        if matches!(direction, Direction::Inbound | Direction::Both) {
71            neighbors.extend(reverse.get(node).into_iter().flatten().copied());
72        }
73        if matches!(direction, Direction::Outbound | Direction::Both) {
74            neighbors.extend(forward.get(node).into_iter().flatten().copied());
75        }
76        for next in neighbors {
77            if visited.insert(next) {
78                reached.push((next, node, depth + 1));
79                queue.push_back((next, depth + 1));
80            }
81        }
82    }
83
84    let mut impacted: Vec<Impacted> = reached
85        .into_iter()
86        .map(|(node, via, depth)| Impacted {
87            node: node.to_string(),
88            via: via.to_string(),
89            depth,
90            impact_radius: reverse_reach_count(&reverse, node),
91            betweenness: betweenness.get(node).copied().unwrap_or(0.0),
92            fix: format!(
93                "{via} may change — review {node} to ensure it still accurately reflects {via}"
94            ),
95        })
96        .collect();
97
98    // Review priority: high impact_radius at shallow depth first, then betweenness.
99    impacted.sort_by(|a, b| {
100        let pa = a.impact_radius as f64 / a.depth as f64 + a.betweenness;
101        let pb = b.impact_radius as f64 / b.depth as f64 + b.betweenness;
102        pb.partial_cmp(&pa)
103            .unwrap_or(std::cmp::Ordering::Equal)
104            .then_with(|| a.node.cmp(&b.node))
105    });
106    impacted
107}
108
109/// Build forward (source → targets) and reverse (target → sources) adjacency
110/// from the composed graph's edges.
111fn adjacency(graph: &Graph) -> (Adjacency<'_>, Adjacency<'_>) {
112    let mut forward: Adjacency = HashMap::new();
113    let mut reverse: Adjacency = HashMap::new();
114    for edge in &graph.edges {
115        forward
116            .entry(edge.source.as_str())
117            .or_default()
118            .push(edge.target.as_str());
119        reverse
120            .entry(edge.target.as_str())
121            .or_default()
122            .push(edge.source.as_str());
123    }
124    (forward, reverse)
125}
126
127/// Count the nodes transitively reachable from `node` along reverse edges — its
128/// transitive dependents.
129fn reverse_reach_count(reverse: &Adjacency, node: &str) -> usize {
130    let mut visited: HashSet<&str> = HashSet::new();
131    visited.insert(node);
132    let mut queue: VecDeque<&str> = VecDeque::new();
133    queue.push_back(node);
134    let mut count = 0;
135    while let Some(current) = queue.pop_front() {
136        for &dependent in reverse.get(current).into_iter().flatten() {
137            if visited.insert(dependent) {
138                count += 1;
139                queue.push_back(dependent);
140            }
141        }
142    }
143    count
144}
145
146/// Betweenness centrality (Brandes' algorithm, directed) over the resolved node
147/// set and internal edges (both endpoints are graph nodes).
148fn betweenness(graph: &Graph) -> HashMap<&str, f64> {
149    let nodes: Vec<&str> = graph.nodes.keys().map(String::as_str).collect();
150    let mut centrality: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
151
152    let n = nodes.len();
153    if n <= 2 {
154        return centrality;
155    }
156
157    // Internal forward adjacency: edges whose target is a graph node.
158    let node_set: HashSet<&str> = nodes.iter().copied().collect();
159    let mut forward: HashMap<&str, Vec<&str>> = HashMap::new();
160    for edge in &graph.edges {
161        if node_set.contains(edge.target.as_str()) {
162            forward
163                .entry(edge.source.as_str())
164                .or_default()
165                .push(edge.target.as_str());
166        }
167    }
168
169    for &s in &nodes {
170        let mut stack: Vec<&str> = Vec::new();
171        let mut predecessors: HashMap<&str, Vec<&str>> =
172            nodes.iter().map(|&n| (n, Vec::new())).collect();
173        let mut sigma: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
174        let mut dist: HashMap<&str, i64> = nodes.iter().map(|&n| (n, -1)).collect();
175
176        sigma.insert(s, 1.0);
177        dist.insert(s, 0);
178
179        let mut queue = VecDeque::new();
180        queue.push_back(s);
181
182        while let Some(v) = queue.pop_front() {
183            stack.push(v);
184            let v_dist = dist[v];
185            for &w in forward.get(v).into_iter().flatten() {
186                if dist[w] < 0 {
187                    dist.insert(w, v_dist + 1);
188                    queue.push_back(w);
189                }
190                if dist[w] == v_dist + 1 {
191                    *sigma.get_mut(w).unwrap() += sigma[v];
192                    predecessors.get_mut(w).unwrap().push(v);
193                }
194            }
195        }
196
197        let mut delta: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
198        while let Some(w) = stack.pop() {
199            // Each w is popped once, so its predecessor list is read once — take
200            // it instead of cloning.
201            let preds = std::mem::take(predecessors.get_mut(w).unwrap());
202            for v in preds {
203                let contribution = (sigma[v] / sigma[w]) * (1.0 + delta[w]);
204                *delta.get_mut(v).unwrap() += contribution;
205            }
206            if w != s {
207                *centrality.get_mut(w).unwrap() += delta[w];
208            }
209        }
210    }
211
212    let norm = ((n - 1) * (n - 2)) as f64;
213    for score in centrality.values_mut() {
214        *score /= norm;
215    }
216    centrality
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::compose::compose;
223    use crate::model::{Edge, GraphSet, Node};
224    use serde_json::json;
225
226    fn fs_node() -> Node {
227        Node::new(
228            json!({ "type": "file", "hash": "b3:x" })
229                .as_object()
230                .unwrap()
231                .clone(),
232        )
233    }
234
235    /// Build a composed graph from (source, target) edges; every referenced path
236    /// becomes an fs node.
237    fn graph_from(edges: &[(&str, &str)]) -> Graph {
238        let mut fs = Graph::labeled("fs");
239        for (s, t) in edges {
240            fs.set_node(*s, fs_node());
241            fs.set_node(*t, fs_node());
242            fs.add_edge(Edge::new(*s, *t));
243        }
244        compose(&GraphSet::new(vec![fs]))
245    }
246
247    fn impacted_nodes(impacted: &[Impacted]) -> Vec<&str> {
248        impacted.iter().map(|i| i.node.as_str()).collect()
249    }
250
251    #[test]
252    fn inbound_finds_transitive_dependents() {
253        // a -> b -> c: impact of c is b (depth 1) and a (depth 2).
254        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
255        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
256        let nodes = impacted_nodes(&impacted);
257        assert!(nodes.contains(&"b.md"));
258        assert!(nodes.contains(&"a.md"));
259        let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
260        assert_eq!(b.depth, 1);
261        assert_eq!(b.via, "c.md");
262        let a = impacted.iter().find(|i| i.node == "a.md").unwrap();
263        assert_eq!(a.depth, 2);
264    }
265
266    #[test]
267    fn cycle_terminates() {
268        // a -> b -> c -> a: impact of a is b and c, no infinite loop.
269        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md"), ("c.md", "a.md")]);
270        let impacted = compute(&graph, &["a.md".into()], Direction::Inbound, None);
271        let nodes = impacted_nodes(&impacted);
272        assert_eq!(nodes.len(), 2, "seed excluded, others reached once");
273        assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
274    }
275
276    #[test]
277    fn depth_limit_truncates() {
278        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
279        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, Some(1));
280        let nodes = impacted_nodes(&impacted);
281        assert_eq!(nodes, vec!["b.md"], "depth 1 stops before a.md");
282    }
283
284    #[test]
285    fn outbound_finds_dependencies() {
286        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
287        let impacted = compute(&graph, &["a.md".into()], Direction::Outbound, None);
288        let nodes = impacted_nodes(&impacted);
289        assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
290    }
291
292    #[test]
293    fn radius_counts_transitive_dependents() {
294        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
295        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
296        // b.md has one transitive dependent (a.md).
297        let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
298        assert_eq!(b.impact_radius, 1);
299    }
300}