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/// A neighbor reached across an edge, carrying that edge's source line numbers
14/// (the `lines` metadata, unioned across parsers) when present.
15struct Neighbor<'a> {
16    node: &'a str,
17    lines: Vec<usize>,
18}
19
20/// Adjacency: a path mapped to the neighbors it connects to.
21type Adjacency<'a> = HashMap<&'a str, Vec<Neighbor<'a>>>;
22
23/// Render line numbers as a `:n,m` suffix, or empty when there are none.
24fn lines_suffix(lines: &[usize]) -> String {
25    if lines.is_empty() {
26        return String::new();
27    }
28    let joined = lines
29        .iter()
30        .map(usize::to_string)
31        .collect::<Vec<_>>()
32        .join(",");
33    format!(":{joined}")
34}
35
36/// Which way to walk from the seed. Inbound (the default) finds dependents —
37/// what must be reviewed when the seed changes.
38#[derive(Clone, Copy, PartialEq, Eq)]
39pub enum Direction {
40    Inbound,
41    Outbound,
42    Both,
43}
44
45/// One impacted node with its causal chain and ranking metrics.
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct Impacted {
48    pub node: String,
49    /// The neighbor through which this node was reached.
50    pub via: String,
51    pub depth: usize,
52    /// Count of nodes that transitively depend on this node.
53    pub impact_radius: usize,
54    /// Betweenness centrality of this node in the whole graph.
55    pub betweenness: f64,
56    /// Source line(s) in `node` where it links `via`. Populated for inbound
57    /// (review) results — the line(s) most likely to need updating. Empty for
58    /// outbound, where the link lives in `via`, not in `node`.
59    pub lines: Vec<usize>,
60    pub fix: String,
61}
62
63impl Impacted {
64    /// The node path annotated with its link line(s), e.g. `README.md:42`.
65    pub fn location(&self) -> String {
66        format!("{}{}", self.node, lines_suffix(&self.lines))
67    }
68}
69
70/// Compute the impacted set for `seeds`. Seeds must be node paths present in
71/// `graph`; unknown seeds are skipped.
72pub fn compute(
73    graph: &Graph,
74    seeds: &[String],
75    direction: Direction,
76    max_depth: Option<usize>,
77) -> Vec<Impacted> {
78    let (forward, reverse) = adjacency(graph);
79    let betweenness = betweenness(graph);
80
81    let mut visited: HashSet<&str> = HashSet::new();
82    let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
83
84    for seed in seeds {
85        if let Some((key, _)) = graph.nodes.get_key_value(seed) {
86            let seed = key.as_str();
87            if visited.insert(seed) {
88                queue.push_back((seed, 0));
89            }
90        }
91    }
92
93    // (node, via, depth, lines-in-node)
94    let mut reached: Vec<(&str, &str, usize, Vec<usize>)> = Vec::new();
95
96    while let Some((node, depth)) = queue.pop_front() {
97        if max_depth.is_some_and(|max| depth >= max) {
98            continue;
99        }
100        // Reverse neighbors are dependents: the reached node holds the link, so
101        // its lines are actionable. Forward neighbors are dependencies: the link
102        // lives in `node`, not in the reached node, so no lines attach.
103        let mut neighbors: Vec<(&str, Vec<usize>)> = Vec::new();
104        if matches!(direction, Direction::Inbound | Direction::Both) {
105            for n in reverse.get(node).into_iter().flatten() {
106                neighbors.push((n.node, n.lines.clone()));
107            }
108        }
109        if matches!(direction, Direction::Outbound | Direction::Both) {
110            for n in forward.get(node).into_iter().flatten() {
111                neighbors.push((n.node, Vec::new()));
112            }
113        }
114        for (next, lines) in neighbors {
115            if visited.insert(next) {
116                reached.push((next, node, depth + 1, lines));
117                queue.push_back((next, depth + 1));
118            }
119        }
120    }
121
122    let mut impacted: Vec<Impacted> = reached
123        .into_iter()
124        .map(|(node, via, depth, lines)| {
125            let locator = lines_suffix(&lines);
126            Impacted {
127                node: node.to_string(),
128                via: via.to_string(),
129                depth,
130                impact_radius: reverse_reach_count(&reverse, node),
131                betweenness: betweenness.get(node).copied().unwrap_or(0.0),
132                lines,
133                fix: format!(
134                    "{via} may change — review {node}{locator} to ensure it still accurately reflects {via}"
135                ),
136            }
137        })
138        .collect();
139
140    // Review priority: high impact_radius at shallow depth first, then betweenness.
141    impacted.sort_by(|a, b| {
142        let pa = a.impact_radius as f64 / a.depth as f64 + a.betweenness;
143        let pb = b.impact_radius as f64 / b.depth as f64 + b.betweenness;
144        pb.partial_cmp(&pa)
145            .unwrap_or(std::cmp::Ordering::Equal)
146            .then_with(|| a.node.cmp(&b.node))
147    });
148    impacted
149}
150
151/// Build forward (source → targets) and reverse (target → sources) adjacency
152/// from the composed graph's edges.
153fn adjacency(graph: &Graph) -> (Adjacency<'_>, Adjacency<'_>) {
154    let mut forward: Adjacency = HashMap::new();
155    let mut reverse: Adjacency = HashMap::new();
156    for edge in &graph.edges {
157        let lines = edge.lines();
158        forward
159            .entry(edge.source.as_str())
160            .or_default()
161            .push(Neighbor {
162                node: edge.target.as_str(),
163                lines: lines.clone(),
164            });
165        reverse
166            .entry(edge.target.as_str())
167            .or_default()
168            .push(Neighbor {
169                node: edge.source.as_str(),
170                lines,
171            });
172    }
173    (forward, reverse)
174}
175
176/// Count the nodes transitively reachable from `node` along reverse edges — its
177/// transitive dependents.
178fn reverse_reach_count(reverse: &Adjacency, node: &str) -> usize {
179    let mut visited: HashSet<&str> = HashSet::new();
180    visited.insert(node);
181    let mut queue: VecDeque<&str> = VecDeque::new();
182    queue.push_back(node);
183    let mut count = 0;
184    while let Some(current) = queue.pop_front() {
185        for neighbor in reverse.get(current).into_iter().flatten() {
186            let dependent = neighbor.node;
187            if visited.insert(dependent) {
188                count += 1;
189                queue.push_back(dependent);
190            }
191        }
192    }
193    count
194}
195
196/// Betweenness centrality (Brandes' algorithm, directed) over the resolved node
197/// set and internal edges (both endpoints are graph nodes).
198fn betweenness(graph: &Graph) -> HashMap<&str, f64> {
199    let nodes: Vec<&str> = graph.nodes.keys().map(String::as_str).collect();
200    let mut centrality: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
201
202    let n = nodes.len();
203    if n <= 2 {
204        return centrality;
205    }
206
207    // Internal forward adjacency: edges whose target is a graph node.
208    let node_set: HashSet<&str> = nodes.iter().copied().collect();
209    let mut forward: HashMap<&str, Vec<&str>> = HashMap::new();
210    for edge in &graph.edges {
211        if node_set.contains(edge.target.as_str()) {
212            forward
213                .entry(edge.source.as_str())
214                .or_default()
215                .push(edge.target.as_str());
216        }
217    }
218
219    for &s in &nodes {
220        let mut stack: Vec<&str> = Vec::new();
221        let mut predecessors: HashMap<&str, Vec<&str>> =
222            nodes.iter().map(|&n| (n, Vec::new())).collect();
223        let mut sigma: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
224        let mut dist: HashMap<&str, i64> = nodes.iter().map(|&n| (n, -1)).collect();
225
226        sigma.insert(s, 1.0);
227        dist.insert(s, 0);
228
229        let mut queue = VecDeque::new();
230        queue.push_back(s);
231
232        while let Some(v) = queue.pop_front() {
233            stack.push(v);
234            let v_dist = dist[v];
235            for &w in forward.get(v).into_iter().flatten() {
236                if dist[w] < 0 {
237                    dist.insert(w, v_dist + 1);
238                    queue.push_back(w);
239                }
240                if dist[w] == v_dist + 1 {
241                    *sigma.get_mut(w).unwrap() += sigma[v];
242                    predecessors.get_mut(w).unwrap().push(v);
243                }
244            }
245        }
246
247        let mut delta: HashMap<&str, f64> = nodes.iter().map(|&n| (n, 0.0)).collect();
248        while let Some(w) = stack.pop() {
249            // Each w is popped once, so its predecessor list is read once — take
250            // it instead of cloning.
251            let preds = std::mem::take(predecessors.get_mut(w).unwrap());
252            for v in preds {
253                let contribution = (sigma[v] / sigma[w]) * (1.0 + delta[w]);
254                *delta.get_mut(v).unwrap() += contribution;
255            }
256            if w != s {
257                *centrality.get_mut(w).unwrap() += delta[w];
258            }
259        }
260    }
261
262    let norm = ((n - 1) * (n - 2)) as f64;
263    for score in centrality.values_mut() {
264        *score /= norm;
265    }
266    centrality
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::compose::compose;
273    use crate::model::{Edge, GraphSet, Metadata, Node};
274    use serde_json::json;
275
276    fn fs_node() -> Node {
277        Node::new(
278            json!({ "type": "file", "hash": "b3:x" })
279                .as_object()
280                .unwrap()
281                .clone(),
282        )
283    }
284
285    /// Build a composed graph from (source, target) edges; every referenced path
286    /// becomes an fs node.
287    fn graph_from(edges: &[(&str, &str)]) -> Graph {
288        let mut fs = Graph::labeled("fs");
289        for (s, t) in edges {
290            fs.set_node(*s, fs_node());
291            fs.set_node(*t, fs_node());
292            fs.add_edge(Edge::new(*s, *t));
293        }
294        compose(&GraphSet::new(vec![fs]))
295    }
296
297    fn impacted_nodes(impacted: &[Impacted]) -> Vec<&str> {
298        impacted.iter().map(|i| i.node.as_str()).collect()
299    }
300
301    #[test]
302    fn inbound_finds_transitive_dependents() {
303        // a -> b -> c: impact of c is b (depth 1) and a (depth 2).
304        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
305        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
306        let nodes = impacted_nodes(&impacted);
307        assert!(nodes.contains(&"b.md"));
308        assert!(nodes.contains(&"a.md"));
309        let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
310        assert_eq!(b.depth, 1);
311        assert_eq!(b.via, "c.md");
312        let a = impacted.iter().find(|i| i.node == "a.md").unwrap();
313        assert_eq!(a.depth, 2);
314    }
315
316    #[test]
317    fn inbound_surfaces_link_lines() {
318        // A dependent links its target on lines 12 and 49. Inbound impact carries
319        // those lines on the dependent and into its fix instruction.
320        let mut g = Graph::labeled("composed");
321        g.set_node("dependent.md", fs_node());
322        g.set_node("dep.md", fs_node());
323        let mut meta = Metadata::new();
324        meta.insert("@markdown".into(), json!({ "lines": [49, 12] }));
325        g.add_edge(Edge::with_metadata("dependent.md", "dep.md", meta));
326
327        let inbound = compute(&g, &["dep.md".into()], Direction::Inbound, None);
328        let d = inbound.iter().find(|i| i.node == "dependent.md").unwrap();
329        assert_eq!(d.lines, vec![12, 49], "sorted, deduped");
330        assert_eq!(d.location(), "dependent.md:12,49");
331        assert!(
332            d.fix.contains("review dependent.md:12,49"),
333            "got: {}",
334            d.fix
335        );
336
337        // Outbound lists the dependency; the link lives in the seed, not here, so
338        // no lines attach.
339        let outbound = compute(&g, &["dependent.md".into()], Direction::Outbound, None);
340        let dep = outbound.iter().find(|i| i.node == "dep.md").unwrap();
341        assert!(dep.lines.is_empty());
342    }
343
344    #[test]
345    fn cycle_terminates() {
346        // a -> b -> c -> a: impact of a is b and c, no infinite loop.
347        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md"), ("c.md", "a.md")]);
348        let impacted = compute(&graph, &["a.md".into()], Direction::Inbound, None);
349        let nodes = impacted_nodes(&impacted);
350        assert_eq!(nodes.len(), 2, "seed excluded, others reached once");
351        assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
352    }
353
354    #[test]
355    fn depth_limit_truncates() {
356        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
357        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, Some(1));
358        let nodes = impacted_nodes(&impacted);
359        assert_eq!(nodes, vec!["b.md"], "depth 1 stops before a.md");
360    }
361
362    #[test]
363    fn outbound_finds_dependencies() {
364        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
365        let impacted = compute(&graph, &["a.md".into()], Direction::Outbound, None);
366        let nodes = impacted_nodes(&impacted);
367        assert!(nodes.contains(&"b.md") && nodes.contains(&"c.md"));
368    }
369
370    #[test]
371    fn radius_counts_transitive_dependents() {
372        let graph = graph_from(&[("a.md", "b.md"), ("b.md", "c.md")]);
373        let impacted = compute(&graph, &["c.md".into()], Direction::Inbound, None);
374        // b.md has one transitive dependent (a.md).
375        let b = impacted.iter().find(|i| i.node == "b.md").unwrap();
376        assert_eq!(b.impact_radius, 1);
377    }
378}