Skip to main content

bvr/analysis/
whatif.rs

1use serde::Serialize;
2
3use crate::model::Issue;
4
5use super::graph::{GraphMetrics, IssueGraph};
6
7/// The delta produced by simulating completion of one issue.
8#[derive(Debug, Clone, Serialize)]
9pub struct WhatIfDelta {
10    pub issue_id: String,
11    pub title: String,
12    /// Issues immediately unblocked (their only remaining blocker was this issue).
13    pub direct_unblocks: Vec<String>,
14    /// All downstream issues transitively unblocked.
15    pub transitive_unblocks: Vec<String>,
16    /// Estimated days saved based on transitive unblock count and average depth.
17    pub estimated_days_saved: f64,
18    /// Change in sum-of-pagerank for remaining open issues (positive = graph got healthier).
19    pub pagerank_delta: f64,
20    /// Number of dependency cycles broken by completing this issue.
21    pub cycles_broken: usize,
22}
23
24/// Compute the what-if delta for completing a single issue.
25pub fn compute_what_if(
26    issues: &[Issue],
27    graph: &IssueGraph,
28    metrics: &GraphMetrics,
29    issue_id: &str,
30) -> Option<WhatIfDelta> {
31    let issue = graph.issue(issue_id)?;
32    let title = issue.title.clone();
33
34    // Direct unblocks: issues where this is the sole open blocker.
35    let dependents = graph.dependents(issue_id);
36    let mut direct_unblocks = Vec::new();
37    for dep_id in &dependents {
38        let blockers = graph.open_blockers(dep_id);
39        // If the only open blocker is the target issue, it gets unblocked.
40        if blockers.len() == 1 && blockers[0] == issue_id {
41            direct_unblocks.push(dep_id.clone());
42        }
43    }
44    direct_unblocks.sort();
45
46    // Transitive unblocks: simulate removing the issue and see what becomes actionable.
47    // Build a modified issue list with the target marked as closed.
48    let modified_issues: Vec<Issue> = issues
49        .iter()
50        .map(|i| {
51            if i.id == issue_id {
52                let mut closed = i.clone();
53                closed.status = "closed".to_string();
54                closed
55            } else {
56                i.clone()
57            }
58        })
59        .collect();
60
61    let modified_graph = IssueGraph::build(&modified_issues);
62    let modified_actionable: std::collections::HashSet<String> =
63        modified_graph.actionable_ids().into_iter().collect();
64    let original_actionable: std::collections::HashSet<String> =
65        graph.actionable_ids().into_iter().collect();
66
67    let mut transitive_unblocks: Vec<String> = modified_actionable
68        .difference(&original_actionable)
69        .filter(|id| *id != issue_id)
70        .cloned()
71        .collect();
72    transitive_unblocks.sort();
73
74    // Estimated days saved: heuristic based on unblock depth.
75    // Each transitively unblocked issue saves ~2 days of blocked time (conservative estimate).
76    let estimated_days_saved = transitive_unblocks.len() as f64 * 2.0;
77
78    // PageRank delta: compare sum of pagerank for open issues before/after.
79    let modified_metrics = modified_graph.compute_metrics();
80    let before_pr_sum: f64 = issues
81        .iter()
82        .filter(|i| i.is_open_like() && i.id != issue_id)
83        .map(|i| metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
84        .sum();
85    let after_pr_sum: f64 = modified_issues
86        .iter()
87        .filter(|i| i.is_open_like() && i.id != issue_id)
88        .map(|i| modified_metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
89        .sum();
90    let pagerank_delta = before_pr_sum - after_pr_sum;
91
92    // Cycles broken: how many cycles contained this issue.
93    let cycles_broken = metrics
94        .cycles
95        .iter()
96        .filter(|cycle| cycle.contains(&issue_id.to_string()))
97        .count();
98
99    Some(WhatIfDelta {
100        issue_id: issue_id.to_string(),
101        title,
102        direct_unblocks,
103        transitive_unblocks,
104        estimated_days_saved,
105        pagerank_delta,
106        cycles_broken,
107    })
108}
109
110/// Compute what-if deltas for all open issues and return the top N by impact.
111pub fn top_what_if_deltas(
112    issues: &[Issue],
113    graph: &IssueGraph,
114    metrics: &GraphMetrics,
115    top_n: usize,
116) -> Vec<WhatIfDelta> {
117    if top_n == 0 {
118        return Vec::new();
119    }
120
121    let mut deltas: Vec<WhatIfDelta> = issues
122        .iter()
123        .filter(|i| i.is_open_like())
124        .filter_map(|i| compute_what_if(issues, graph, metrics, &i.id))
125        .collect();
126
127    // Sort by transitive unblocks (desc), then direct unblocks (desc), then id (asc).
128    deltas.sort_by(|a, b| {
129        b.transitive_unblocks
130            .len()
131            .cmp(&a.transitive_unblocks.len())
132            .then_with(|| b.direct_unblocks.len().cmp(&a.direct_unblocks.len()))
133            .then_with(|| a.issue_id.cmp(&b.issue_id))
134    });
135    deltas.truncate(top_n);
136    deltas
137}
138
139#[cfg(test)]
140mod tests {
141    use crate::model::{Dependency, Issue};
142
143    use super::*;
144
145    fn make_issue(id: &str, status: &str) -> Issue {
146        Issue {
147            id: id.to_string(),
148            title: format!("Issue {id}"),
149            status: status.to_string(),
150            issue_type: "task".to_string(),
151            priority: 2,
152            ..Issue::default()
153        }
154    }
155
156    fn make_blocked(id: &str, depends_on: &str) -> Issue {
157        Issue {
158            id: id.to_string(),
159            title: format!("Issue {id}"),
160            status: "blocked".to_string(),
161            issue_type: "task".to_string(),
162            priority: 2,
163            dependencies: vec![Dependency {
164                issue_id: id.to_string(),
165                depends_on_id: depends_on.to_string(),
166                dep_type: "blocks".to_string(),
167                ..Dependency::default()
168            }],
169            ..Issue::default()
170        }
171    }
172
173    #[test]
174    fn what_if_single_blocker_unblocks_dependent() {
175        let issues = vec![make_issue("A", "open"), make_blocked("B", "A")];
176        let graph = IssueGraph::build(&issues);
177        let metrics = graph.compute_metrics();
178
179        let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
180        assert_eq!(delta.issue_id, "A");
181        assert_eq!(delta.direct_unblocks, vec!["B"]);
182        assert!(delta.transitive_unblocks.contains(&"B".to_string()));
183        assert!(delta.estimated_days_saved >= 2.0);
184    }
185
186    #[test]
187    fn what_if_chain_produces_transitive_unblocks() {
188        // A blocks B, B blocks C
189        let issues = vec![
190            make_issue("A", "open"),
191            make_blocked("B", "A"),
192            Issue {
193                id: "C".to_string(),
194                title: "Issue C".to_string(),
195                status: "blocked".to_string(),
196                issue_type: "task".to_string(),
197                priority: 2,
198                dependencies: vec![Dependency {
199                    issue_id: "C".to_string(),
200                    depends_on_id: "B".to_string(),
201                    dep_type: "blocks".to_string(),
202                    ..Dependency::default()
203                }],
204                ..Issue::default()
205            },
206        ];
207        let graph = IssueGraph::build(&issues);
208        let metrics = graph.compute_metrics();
209
210        let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
211        assert_eq!(delta.direct_unblocks, vec!["B"]);
212        // B becomes actionable (not C yet, since B still needs to be completed)
213        assert!(delta.transitive_unblocks.contains(&"B".to_string()));
214    }
215
216    #[test]
217    fn what_if_no_dependents_returns_empty_unblocks() {
218        let issues = vec![make_issue("A", "open"), make_issue("B", "open")];
219        let graph = IssueGraph::build(&issues);
220        let metrics = graph.compute_metrics();
221
222        let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
223        assert!(delta.direct_unblocks.is_empty());
224        assert!(delta.transitive_unblocks.is_empty());
225        assert!((delta.estimated_days_saved - 0.0).abs() < 1e-6);
226    }
227
228    #[test]
229    fn what_if_nonexistent_issue_returns_none() {
230        let issues = vec![make_issue("A", "open")];
231        let graph = IssueGraph::build(&issues);
232        let metrics = graph.compute_metrics();
233
234        assert!(compute_what_if(&issues, &graph, &metrics, "X").is_none());
235    }
236
237    #[test]
238    fn what_if_empty_graph() {
239        let issues: Vec<Issue> = vec![];
240        let graph = IssueGraph::build(&issues);
241        let metrics = graph.compute_metrics();
242
243        assert!(compute_what_if(&issues, &graph, &metrics, "A").is_none());
244        let deltas = top_what_if_deltas(&issues, &graph, &metrics, 5);
245        assert!(deltas.is_empty());
246    }
247
248    #[test]
249    fn what_if_single_node_graph() {
250        let issues = vec![make_issue("A", "open")];
251        let graph = IssueGraph::build(&issues);
252        let metrics = graph.compute_metrics();
253
254        let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
255        assert!(delta.direct_unblocks.is_empty());
256        assert!(delta.transitive_unblocks.is_empty());
257        assert!((delta.estimated_days_saved - 0.0).abs() < 1e-6);
258    }
259
260    #[test]
261    fn top_what_if_deltas_sorts_by_impact() {
262        // A blocks 3, B blocks 1, C blocks nothing
263        let issues = vec![
264            make_issue("A", "open"),
265            make_issue("B", "open"),
266            make_issue("C", "open"),
267            make_blocked("D1", "A"),
268            make_blocked("D2", "A"),
269            make_blocked("D3", "A"),
270            make_blocked("E1", "B"),
271        ];
272        let graph = IssueGraph::build(&issues);
273        let metrics = graph.compute_metrics();
274
275        let deltas = top_what_if_deltas(&issues, &graph, &metrics, 3);
276        assert!(!deltas.is_empty());
277        // A should be first (most transitive unblocks)
278        assert_eq!(deltas[0].issue_id, "A");
279        // B should be second
280        assert_eq!(deltas[1].issue_id, "B");
281    }
282
283    #[test]
284    fn what_if_respects_top_n_limit() {
285        let issues = vec![
286            make_issue("A", "open"),
287            make_issue("B", "open"),
288            make_issue("C", "open"),
289        ];
290        let graph = IssueGraph::build(&issues);
291        let metrics = graph.compute_metrics();
292
293        let deltas = top_what_if_deltas(&issues, &graph, &metrics, 2);
294        assert!(deltas.len() <= 2);
295    }
296
297    #[test]
298    fn what_if_zero_top_n_returns_no_results() {
299        let issues = vec![
300            make_issue("A", "open"),
301            make_issue("B", "open"),
302            make_blocked("C", "A"),
303        ];
304        let graph = IssueGraph::build(&issues);
305        let metrics = graph.compute_metrics();
306
307        let deltas = top_what_if_deltas(&issues, &graph, &metrics, 0);
308        assert!(deltas.is_empty());
309    }
310
311    #[test]
312    fn what_if_cycle_detection() {
313        let issues = vec![
314            Issue {
315                id: "A".to_string(),
316                title: "In cycle".to_string(),
317                status: "open".to_string(),
318                issue_type: "task".to_string(),
319                dependencies: vec![Dependency {
320                    depends_on_id: "B".to_string(),
321                    dep_type: "blocks".to_string(),
322                    ..Dependency::default()
323                }],
324                ..Issue::default()
325            },
326            Issue {
327                id: "B".to_string(),
328                title: "In cycle".to_string(),
329                status: "open".to_string(),
330                issue_type: "task".to_string(),
331                dependencies: vec![Dependency {
332                    depends_on_id: "A".to_string(),
333                    dep_type: "blocks".to_string(),
334                    ..Dependency::default()
335                }],
336                ..Issue::default()
337            },
338        ];
339        let graph = IssueGraph::build(&issues);
340        let metrics = graph.compute_metrics();
341
342        let delta = compute_what_if(&issues, &graph, &metrics, "A").unwrap();
343        assert!(
344            delta.cycles_broken > 0,
345            "A is in a cycle, should break at least 1"
346        );
347    }
348
349    #[test]
350    fn what_if_serializes_to_json() {
351        let delta = WhatIfDelta {
352            issue_id: "A".to_string(),
353            title: "Test".to_string(),
354            direct_unblocks: vec!["B".to_string()],
355            transitive_unblocks: vec!["B".to_string(), "C".to_string()],
356            estimated_days_saved: 4.0,
357            pagerank_delta: 0.05,
358            cycles_broken: 0,
359        };
360
361        let json = serde_json::to_value(&delta).unwrap();
362        assert_eq!(json["issue_id"], "A");
363        assert_eq!(json["direct_unblocks"], serde_json::json!(["B"]));
364        assert_eq!(json["estimated_days_saved"], 4.0);
365        assert_eq!(json["cycles_broken"], 0);
366    }
367
368    #[test]
369    fn what_if_large_graph_correctness() {
370        // Build a chain of 100 issues: I0 → I1 → I2 → ... → I99
371        let mut issues = vec![make_issue("I0", "open")];
372        for i in 1..100 {
373            issues.push(make_blocked(&format!("I{i}"), &format!("I{}", i - 1)));
374        }
375
376        let graph = IssueGraph::build(&issues);
377        let metrics = graph.compute_metrics();
378
379        // Completing I0 should directly unblock I1
380        let delta = compute_what_if(&issues, &graph, &metrics, "I0").unwrap();
381        assert_eq!(delta.direct_unblocks, vec!["I1"]);
382        // Transitively only I1 becomes actionable (I2 still blocked by I1)
383        assert!(delta.transitive_unblocks.contains(&"I1".to_string()));
384        assert!(
385            !delta.transitive_unblocks.contains(&"I2".to_string()),
386            "I2 should still be blocked by I1"
387        );
388
389        // Top deltas: I0 should rank high (most downstream impact)
390        let deltas = top_what_if_deltas(&issues, &graph, &metrics, 5);
391        assert!(!deltas.is_empty());
392        // All scores should be valid
393        for d in &deltas {
394            assert!(d.estimated_days_saved >= 0.0);
395        }
396    }
397}