Skip to main content

bvr/analysis/
advanced.rs

1//! Advanced graph insights: `TopKSet`, `CoverageSet`, `KPaths`, `CycleBreak`, `ParallelCut`, `ParallelGain`.
2//!
3//! Implements six advanced analysis algorithms that build on the core graph metrics.
4
5use std::collections::{HashMap, HashSet, VecDeque};
6
7use serde::Serialize;
8
9use crate::analysis::graph::{GraphMetrics, IssueGraph};
10use crate::model::Issue;
11
12// ---------------------------------------------------------------------------
13// Public types
14// ---------------------------------------------------------------------------
15
16/// Aggregates all six advanced insight types.
17#[derive(Debug, Clone, Serialize)]
18pub struct AdvancedInsights {
19    pub top_k_set: TopKSetResult,
20    pub coverage_set: CoverageSetResult,
21    pub k_paths: KPathsResult,
22    pub cycle_break: CycleBreakResult,
23    pub parallel_cut: ParallelCutResult,
24    pub parallel_gain: ParallelGainResult,
25    pub config: AdvancedInsightsConfig,
26    pub usage_hints: Vec<String>,
27}
28
29/// Configuration used to produce the advanced insights.
30#[derive(Debug, Clone, Serialize)]
31pub struct AdvancedInsightsConfig {
32    pub top_k: usize,
33    pub k_paths_k: usize,
34    pub max_cycle_break: usize,
35    pub max_parallel_cut: usize,
36}
37
38impl Default for AdvancedInsightsConfig {
39    fn default() -> Self {
40        Self {
41            top_k: 10,
42            k_paths_k: 5,
43            max_cycle_break: 10,
44            max_parallel_cut: 10,
45        }
46    }
47}
48
49// ---------------------------------------------------------------------------
50// 1. TopKSet – greedy submodular set of issues that maximize downstream unlocks
51// ---------------------------------------------------------------------------
52
53#[derive(Debug, Clone, Serialize)]
54pub struct TopKSetResult {
55    pub items: Vec<TopKItem>,
56    pub total_unlocked: usize,
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct TopKItem {
61    pub id: String,
62    pub marginal_unlocks: usize,
63    pub cumulative_unlocks: usize,
64}
65
66/// Greedy selection of up to `k` issues whose completion maximally unlocks downstream work.
67///
68/// At each step we pick the open issue whose completion would unblock the most new downstream
69/// issues (that are not already unblocked by previously selected items).
70///
71/// This is exposed `pub` so callers that only need the unlock ranking can
72/// skip the full `compute_advanced_insights` which also computes coverage,
73/// k-paths, cycle-break, parallel-cut, and parallel-gain.
74#[must_use]
75pub fn compute_top_k_set(graph: &IssueGraph, _metrics: &GraphMetrics, k: usize) -> TopKSetResult {
76    let open_ids: Vec<String> = graph
77        .issue_ids_sorted()
78        .into_iter()
79        .filter(|id| graph.issue(id).is_some_and(Issue::is_open_like))
80        .collect();
81
82    // Pre-compute transitive downstream set for each open issue (BFS through dependents).
83    let downstream_map: HashMap<String, HashSet<String>> = open_ids
84        .iter()
85        .map(|id| {
86            let downstream = bfs_downstream(graph, id);
87            (id.clone(), downstream)
88        })
89        .collect();
90
91    let mut selected = Vec::new();
92    let mut already_unlocked = HashSet::<String>::new();
93    let mut remaining: HashSet<String> = open_ids.into_iter().collect();
94
95    for _ in 0..k {
96        if remaining.is_empty() {
97            break;
98        }
99
100        // Find the issue whose downstream set gains the most new unlocks.
101        let best = remaining
102            .iter()
103            .map(|id| {
104                let downstream = downstream_map.get(id).map_or(0, |set| {
105                    set.iter()
106                        .filter(|d| !already_unlocked.contains(*d) && *d != id)
107                        .count()
108                });
109                (id.clone(), downstream)
110            })
111            .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)));
112
113        let Some((best_id, marginal)) = best else {
114            break;
115        };
116
117        if marginal == 0 && !selected.is_empty() {
118            break;
119        }
120
121        // Add newly unlocked downstream issues, but do not count the selected
122        // issue itself as an "unlocked" downstream item.
123        if let Some(ds) = downstream_map.get(&best_id) {
124            for d in ds {
125                already_unlocked.insert(d.clone());
126            }
127        }
128        remaining.remove(&best_id);
129
130        selected.push(TopKItem {
131            id: best_id,
132            marginal_unlocks: marginal,
133            cumulative_unlocks: already_unlocked.len(),
134        });
135    }
136
137    TopKSetResult {
138        total_unlocked: already_unlocked.len(),
139        items: selected,
140    }
141}
142
143/// BFS downstream through dependents (issues that depend on `start_id`).
144fn bfs_downstream(graph: &IssueGraph, start_id: &str) -> HashSet<String> {
145    let mut visited = HashSet::new();
146    let mut queue = VecDeque::new();
147    queue.push_back(start_id.to_string());
148
149    while let Some(current) = queue.pop_front() {
150        for dep in graph.dependents(&current) {
151            if visited.insert(dep.clone()) {
152                queue.push_back(dep);
153            }
154        }
155    }
156    visited
157}
158
159// ---------------------------------------------------------------------------
160// 2. CoverageSet – greedy vertex cover of the critical path DAG
161// ---------------------------------------------------------------------------
162
163#[derive(Debug, Clone, Serialize)]
164pub struct CoverageSetResult {
165    pub items: Vec<String>,
166    pub paths_covered: usize,
167    pub total_paths: usize,
168}
169
170/// Greedy minimum vertex cover: select issues that appear on the most critical paths.
171///
172/// We approximate by collecting all edges on the critical sub-DAG (issues with non-zero
173/// critical depth) and greedily covering them.
174fn compute_coverage_set(graph: &IssueGraph, metrics: &GraphMetrics) -> CoverageSetResult {
175    // Collect edges where both endpoints have critical_depth > 0.
176    let mut edges: Vec<(String, String)> = Vec::new();
177    let critical_ids: HashSet<&String> = metrics
178        .critical_depth
179        .iter()
180        .filter(|(_, d)| **d > 0)
181        .map(|(id, _)| id)
182        .collect();
183
184    for id in &critical_ids {
185        for dep in graph.dependents(id) {
186            if critical_ids.contains(&dep) {
187                edges.push(((*id).clone(), dep));
188            }
189        }
190    }
191
192    let total_paths = edges.len();
193    let mut uncovered = edges;
194    let mut selected = Vec::new();
195
196    while !uncovered.is_empty() {
197        // Count how many uncovered edges each node touches.
198        let mut freq: HashMap<String, usize> = HashMap::new();
199        for (a, b) in &uncovered {
200            *freq.entry(a.clone()).or_default() += 1;
201            *freq.entry(b.clone()).or_default() += 1;
202        }
203
204        // Pick the node with highest frequency (ties broken by ID).
205        let best = freq
206            .into_iter()
207            .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)));
208
209        let Some((best_id, _)) = best else {
210            break;
211        };
212
213        // Remove all edges touching best_id.
214        uncovered.retain(|(a, b)| a != &best_id && b != &best_id);
215        selected.push(best_id);
216    }
217
218    selected.sort();
219
220    CoverageSetResult {
221        paths_covered: total_paths,
222        total_paths,
223        items: selected,
224    }
225}
226
227// ---------------------------------------------------------------------------
228// 3. KPaths – K shortest critical paths (simplified Yen's algorithm)
229// ---------------------------------------------------------------------------
230
231#[derive(Debug, Clone, Serialize)]
232pub struct KPathsResult {
233    pub paths: Vec<CriticalPath>,
234    pub k: usize,
235}
236
237#[derive(Debug, Clone, Serialize)]
238pub struct CriticalPath {
239    pub path: Vec<String>,
240    pub length: usize,
241    pub depth_sum: usize,
242}
243
244/// Find up to `k` longest (most critical) paths through the dependency graph.
245///
246/// Uses iterative DFS from source nodes (those with no open blockers) to sink nodes
247/// (those with no open dependents), collecting the longest paths first.
248fn compute_k_paths(graph: &IssueGraph, metrics: &GraphMetrics, k: usize) -> KPathsResult {
249    let open_ids: HashSet<String> = graph
250        .issue_ids_sorted()
251        .into_iter()
252        .filter(|id| graph.issue(id).is_some_and(Issue::is_open_like))
253        .collect();
254
255    if open_ids.is_empty() {
256        return KPathsResult {
257            paths: Vec::new(),
258            k,
259        };
260    }
261
262    // Source nodes: open issues with no open blockers.
263    let sources: Vec<String> = open_ids
264        .iter()
265        .filter(|id| graph.open_blockers(id).is_empty())
266        .cloned()
267        .collect();
268
269    // DFS from each source to find all maximal paths.
270    let mut all_paths: Vec<Vec<String>> = Vec::new();
271    let path_cap = (10 * k).min(10_000);
272
273    'outer: for source in &sources {
274        let mut stack: Vec<(String, Vec<String>)> = vec![(source.clone(), vec![source.clone()])];
275        let mut visited_from_source = HashSet::new();
276
277        while let Some((current, path)) = stack.pop() {
278            let deps: Vec<String> = graph
279                .dependents(&current)
280                .into_iter()
281                .filter(|d| open_ids.contains(d) && !path.contains(d))
282                .collect();
283
284            if deps.is_empty() {
285                // Leaf path — only keep non-trivial paths.
286                if path.len() > 1 {
287                    all_paths.push(path);
288                    if all_paths.len() >= path_cap {
289                        break 'outer;
290                    }
291                }
292            } else {
293                for dep in deps {
294                    if visited_from_source.insert((current.clone(), dep.clone())) {
295                        let mut new_path = path.clone();
296                        new_path.push(dep.clone());
297                        stack.push((dep, new_path));
298                    }
299                }
300            }
301        }
302    }
303
304    // Score by length then depth_sum, take top k.
305    let mut scored: Vec<CriticalPath> = all_paths
306        .into_iter()
307        .map(|path| {
308            let depth_sum: usize = path
309                .iter()
310                .filter_map(|id| metrics.critical_depth.get(id))
311                .sum();
312            let length = path.len();
313            CriticalPath {
314                path,
315                length,
316                depth_sum,
317            }
318        })
319        .collect();
320
321    scored.sort_by(|a, b| {
322        b.length
323            .cmp(&a.length)
324            .then_with(|| b.depth_sum.cmp(&a.depth_sum))
325    });
326    scored.truncate(k);
327
328    KPathsResult { paths: scored, k }
329}
330
331// ---------------------------------------------------------------------------
332// 4. CycleBreak – minimum feedback arc set (greedy approximation)
333// ---------------------------------------------------------------------------
334
335#[derive(Debug, Clone, Serialize)]
336pub struct CycleBreakResult {
337    pub suggestions: Vec<CycleBreakSuggestion>,
338    pub cycles_before: usize,
339    pub estimated_cycles_after: usize,
340}
341
342#[derive(Debug, Clone, Serialize)]
343pub struct CycleBreakSuggestion {
344    pub from: String,
345    pub to: String,
346    pub cycles_broken: usize,
347    pub collateral_score: f64,
348}
349
350/// Suggest edges to remove to break dependency cycles with minimal collateral damage.
351///
352/// Greedy: at each step, find the edge appearing in the most remaining cycles,
353/// weighted inversely by the edge's importance (pagerank of endpoints).
354fn compute_cycle_break(
355    graph: &IssueGraph,
356    metrics: &GraphMetrics,
357    max_suggestions: usize,
358) -> CycleBreakResult {
359    let cycles_before = metrics.cycles.len();
360
361    if cycles_before == 0 {
362        return CycleBreakResult {
363            suggestions: Vec::new(),
364            cycles_before: 0,
365            estimated_cycles_after: 0,
366        };
367    }
368
369    // Collect all edges within cycles.
370    let mut remaining_cycles: Vec<Vec<String>> = metrics.cycles.clone();
371    let mut suggestions = Vec::new();
372    let mut estimated_remaining = remaining_cycles.len();
373
374    for _ in 0..max_suggestions {
375        if remaining_cycles.is_empty() {
376            break;
377        }
378
379        // Count edge frequency across remaining cycles.
380        let mut edge_freq: HashMap<(String, String), usize> = HashMap::new();
381        for cycle in &remaining_cycles {
382            // Edges within the SCC: check actual dependency edges between cycle members.
383            let cycle_set: HashSet<&String> = cycle.iter().collect();
384            for member in cycle {
385                for blocker in graph.blockers(member) {
386                    if cycle_set.contains(&blocker) {
387                        *edge_freq.entry((blocker, member.clone())).or_default() += 1;
388                    }
389                }
390            }
391        }
392
393        if edge_freq.is_empty() {
394            break;
395        }
396
397        // Pick edge with highest frequency; break ties by lowest collateral (pagerank sum).
398        let best = edge_freq
399            .iter()
400            .map(|((from, to), freq)| {
401                let pr_from = metrics.pagerank.get(from).copied().unwrap_or_default();
402                let pr_to = metrics.pagerank.get(to).copied().unwrap_or_default();
403                let collateral = pr_from + pr_to;
404                (from.clone(), to.clone(), *freq, collateral)
405            })
406            .max_by(|a, b| {
407                a.2.cmp(&b.2)
408                    .then_with(|| a.3.total_cmp(&b.3).reverse())
409                    .then_with(|| b.0.cmp(&a.0))
410            });
411
412        let Some((from, to, freq, collateral)) = best else {
413            break;
414        };
415
416        // Remove cycles that contained this edge.
417        remaining_cycles.retain(|cycle| {
418            let set: HashSet<&String> = cycle.iter().collect();
419            !(set.contains(&from) && set.contains(&to))
420        });
421
422        estimated_remaining = remaining_cycles.len();
423
424        suggestions.push(CycleBreakSuggestion {
425            from,
426            to,
427            cycles_broken: freq,
428            collateral_score: collateral,
429        });
430    }
431
432    CycleBreakResult {
433        suggestions,
434        cycles_before,
435        estimated_cycles_after: estimated_remaining,
436    }
437}
438
439// ---------------------------------------------------------------------------
440// 5. ParallelCut – edges whose removal maximizes parallelization
441// ---------------------------------------------------------------------------
442
443#[derive(Debug, Clone, Serialize)]
444pub struct ParallelCutResult {
445    pub cuts: Vec<ParallelCutEdge>,
446    pub current_serial_depth: usize,
447    pub estimated_depth_after: usize,
448}
449
450#[derive(Debug, Clone, Serialize)]
451pub struct ParallelCutEdge {
452    pub from: String,
453    pub to: String,
454    pub depth_reduction: usize,
455}
456
457/// Find edges on the critical path whose removal would reduce the longest chain.
458///
459/// Heuristic: edges between issues with high `critical_depth` differences that sit on
460/// the longest chain are candidates for removal to increase parallelism.
461fn compute_parallel_cut(
462    graph: &IssueGraph,
463    metrics: &GraphMetrics,
464    max_cuts: usize,
465) -> ParallelCutResult {
466    let max_depth = metrics.critical_depth.values().copied().max().unwrap_or(0);
467
468    if max_depth == 0 {
469        return ParallelCutResult {
470            cuts: Vec::new(),
471            current_serial_depth: 0,
472            estimated_depth_after: 0,
473        };
474    }
475
476    // Find edges on the critical path (where both endpoints are on critical path
477    // and depth decreases by exactly 1).
478    let mut candidates: Vec<ParallelCutEdge> = Vec::new();
479
480    for id in graph.issue_ids_sorted() {
481        let depth = metrics.critical_depth.get(&id).copied().unwrap_or(0);
482        if depth == 0 {
483            continue;
484        }
485
486        for blocker in graph.blockers(&id) {
487            let blocker_depth = metrics.critical_depth.get(&blocker).copied().unwrap_or(0);
488            // An edge is on the critical path if the blocker's depth == our depth + 1
489            // (blocker is one level above dependent in the critical chain).
490            if blocker_depth == depth + 1 {
491                candidates.push(ParallelCutEdge {
492                    from: blocker,
493                    to: id.clone(),
494                    depth_reduction: 1,
495                });
496            }
497        }
498    }
499
500    // Sort by depth of 'from' node (higher = more impactful cut), descending.
501    candidates.sort_by(|a, b| {
502        let a_depth = metrics.critical_depth.get(&a.from).copied().unwrap_or(0);
503        let b_depth = metrics.critical_depth.get(&b.from).copied().unwrap_or(0);
504        b_depth
505            .cmp(&a_depth)
506            .then_with(|| a.from.cmp(&b.from))
507            .then_with(|| a.to.cmp(&b.to))
508    });
509    candidates.truncate(max_cuts);
510
511    let estimated_after = if candidates.is_empty() {
512        max_depth
513    } else {
514        max_depth.saturating_sub(1)
515    };
516
517    ParallelCutResult {
518        cuts: candidates,
519        current_serial_depth: max_depth,
520        estimated_depth_after: estimated_after,
521    }
522}
523
524// ---------------------------------------------------------------------------
525// 6. ParallelGain – estimate throughput gain from removing dependencies
526// ---------------------------------------------------------------------------
527
528#[derive(Debug, Clone, Serialize)]
529pub struct ParallelGainResult {
530    pub current_components: usize,
531    pub current_max_chain: usize,
532    pub gains: Vec<ParallelGainItem>,
533}
534
535#[derive(Debug, Clone, Serialize)]
536pub struct ParallelGainItem {
537    pub edge_from: String,
538    pub edge_to: String,
539    pub new_components: usize,
540    pub new_max_chain: usize,
541    pub parallelism_delta: f64,
542}
543
544/// Estimate how much parallelism each edge removal would provide.
545///
546/// For each critical-path edge, we simulate removal and measure:
547/// - Change in number of connected components (more = more parallel tracks).
548/// - Change in longest chain length (shorter = faster completion with parallel agents).
549fn compute_parallel_gain(
550    graph: &IssueGraph,
551    metrics: &GraphMetrics,
552    max_items: usize,
553) -> ParallelGainResult {
554    let open_ids: HashSet<String> = graph
555        .issue_ids_sorted()
556        .into_iter()
557        .filter(|id| graph.issue(id).is_some_and(Issue::is_open_like))
558        .collect();
559
560    let current_components = graph.connected_open_components().len();
561    let current_max = metrics.critical_depth.values().copied().max().unwrap_or(0);
562
563    if open_ids.is_empty() || current_max == 0 {
564        return ParallelGainResult {
565            current_components,
566            current_max_chain: current_max,
567            gains: Vec::new(),
568        };
569    }
570
571    // Collect critical-path edges (same logic as parallel_cut).
572    let mut critical_edges: Vec<(String, String)> = Vec::new();
573    for id in &open_ids {
574        let depth = metrics.critical_depth.get(id).copied().unwrap_or(0);
575        if depth == 0 {
576            continue;
577        }
578        for blocker in graph.blockers(id) {
579            if !open_ids.contains(&blocker) {
580                continue;
581            }
582            let blocker_depth = metrics.critical_depth.get(&blocker).copied().unwrap_or(0);
583            if blocker_depth == depth + 1 {
584                critical_edges.push((blocker, id.clone()));
585            }
586        }
587    }
588
589    // For each critical edge, simulate removal and compute new component count + max chain.
590    let mut gains: Vec<ParallelGainItem> = critical_edges
591        .iter()
592        .map(|(from, to)| {
593            let (new_components, new_max) =
594                simulate_edge_removal(graph, &open_ids, metrics, from, to);
595            let parallelism_delta = if current_max > 0 {
596                (current_max as f64 - new_max as f64) / current_max as f64
597            } else {
598                0.0
599            };
600            ParallelGainItem {
601                edge_from: from.clone(),
602                edge_to: to.clone(),
603                new_components,
604                new_max_chain: new_max,
605                parallelism_delta,
606            }
607        })
608        .collect();
609
610    gains.sort_by(|a, b| {
611        b.parallelism_delta
612            .total_cmp(&a.parallelism_delta)
613            .then_with(|| a.edge_from.cmp(&b.edge_from))
614    });
615    gains.truncate(max_items);
616
617    ParallelGainResult {
618        current_components,
619        current_max_chain: current_max,
620        gains,
621    }
622}
623
624/// Simulate removing one edge and compute connected components + max chain length.
625fn simulate_edge_removal(
626    graph: &IssueGraph,
627    open_ids: &HashSet<String>,
628    _metrics: &GraphMetrics,
629    remove_from: &str,
630    remove_to: &str,
631) -> (usize, usize) {
632    // Build adjacency (blockers → dependents) without the removed edge.
633    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
634    let mut rev_adj: HashMap<String, Vec<String>> = HashMap::new();
635
636    for id in open_ids {
637        for blocker in graph.blockers(id) {
638            if !open_ids.contains(&blocker) {
639                continue;
640            }
641            if blocker == remove_from && id == remove_to {
642                continue; // Skip removed edge.
643            }
644            adj.entry(blocker.clone()).or_default().push(id.clone());
645            rev_adj.entry(id.clone()).or_default().push(blocker);
646        }
647    }
648
649    // Connected components (undirected view).
650    let mut visited = HashSet::new();
651    let mut component_count = 0usize;
652    for id in open_ids {
653        if visited.contains(id) {
654            continue;
655        }
656        component_count += 1;
657        let mut queue = VecDeque::new();
658        queue.push_back(id.clone());
659        while let Some(current) = queue.pop_front() {
660            if !visited.insert(current.clone()) {
661                continue;
662            }
663            for neighbor in adj.get(&current).into_iter().flatten() {
664                if !visited.contains(neighbor) {
665                    queue.push_back(neighbor.clone());
666                }
667            }
668            for neighbor in rev_adj.get(&current).into_iter().flatten() {
669                if !visited.contains(neighbor) {
670                    queue.push_back(neighbor.clone());
671                }
672            }
673        }
674    }
675
676    // Max chain: compute critical depth in the modified graph (longest path via BFS/topological).
677    let mut in_degree: HashMap<String, usize> = HashMap::new();
678    for id in open_ids {
679        in_degree.entry(id.clone()).or_default();
680    }
681    for deps in adj.values() {
682        for dep in deps {
683            *in_degree.entry(dep.clone()).or_default() += 1;
684        }
685    }
686
687    let mut depth: HashMap<String, usize> = HashMap::new();
688    let mut queue: VecDeque<String> = in_degree
689        .iter()
690        .filter(|(_, d)| **d == 0)
691        .map(|(id, _)| id.clone())
692        .collect();
693
694    for id in &queue {
695        depth.insert(id.clone(), 0);
696    }
697
698    while let Some(current) = queue.pop_front() {
699        let current_depth = depth.get(&current).copied().unwrap_or(0);
700        for neighbor in adj.get(&current).into_iter().flatten() {
701            let new_depth = current_depth + 1;
702            let entry = depth.entry(neighbor.clone()).or_default();
703            if new_depth > *entry {
704                *entry = new_depth;
705            }
706            if let Some(deg) = in_degree.get_mut(neighbor) {
707                *deg -= 1;
708                if *deg == 0 {
709                    queue.push_back(neighbor.clone());
710                }
711            }
712        }
713    }
714
715    let max_chain = depth.values().copied().max().unwrap_or(0);
716
717    (component_count, max_chain)
718}
719
720// ---------------------------------------------------------------------------
721// Public API
722// ---------------------------------------------------------------------------
723
724/// Compute all advanced insights with default configuration.
725#[must_use]
726pub fn compute_advanced_insights(graph: &IssueGraph, metrics: &GraphMetrics) -> AdvancedInsights {
727    compute_advanced_insights_with_config(graph, metrics, &AdvancedInsightsConfig::default())
728}
729
730/// Compute all advanced insights with custom configuration.
731#[must_use]
732pub fn compute_advanced_insights_with_config(
733    graph: &IssueGraph,
734    metrics: &GraphMetrics,
735    config: &AdvancedInsightsConfig,
736) -> AdvancedInsights {
737    let top_k_set = compute_top_k_set(graph, metrics, config.top_k);
738    let coverage_set = compute_coverage_set(graph, metrics);
739    let k_paths = compute_k_paths(graph, metrics, config.k_paths_k);
740    let cycle_break = compute_cycle_break(graph, metrics, config.max_cycle_break);
741    let parallel_cut = compute_parallel_cut(graph, metrics, config.max_parallel_cut);
742    let parallel_gain = compute_parallel_gain(graph, metrics, config.max_parallel_cut);
743
744    AdvancedInsights {
745        top_k_set,
746        coverage_set,
747        k_paths,
748        cycle_break,
749        parallel_cut,
750        parallel_gain,
751        config: config.clone(),
752        usage_hints: vec![
753            "jq '.top_k_set.items[:5]' — Top 5 issues to unlock the most downstream work"
754                .to_string(),
755            "jq '.coverage_set.items' — Minimal set covering all critical paths".to_string(),
756            "jq '.k_paths.paths[0].path' — Longest critical path through the graph".to_string(),
757            "jq '.cycle_break.suggestions' — Edges to remove to break dependency cycles"
758                .to_string(),
759            "jq '.parallel_cut.cuts' — Edges to remove for maximum parallelization".to_string(),
760            "jq '.parallel_gain.gains[:3]' — Top 3 edges whose removal increases parallelism"
761                .to_string(),
762        ],
763    }
764}
765
766// ---------------------------------------------------------------------------
767// Tests
768// ---------------------------------------------------------------------------
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::model::{Dependency, Issue};
774
775    fn make_issue(id: &str, deps: &[&str]) -> Issue {
776        Issue {
777            id: id.to_string(),
778            title: format!("Issue {id}"),
779            status: "open".to_string(),
780            priority: 2,
781            dependencies: deps
782                .iter()
783                .map(|d| Dependency {
784                    issue_id: id.to_string(),
785                    depends_on_id: d.to_string(),
786                    dep_type: "blocks".to_string(),
787                    ..Dependency::default()
788                })
789                .collect(),
790            ..Default::default()
791        }
792    }
793
794    fn make_closed_issue(id: &str) -> Issue {
795        Issue {
796            id: id.to_string(),
797            title: format!("Issue {id}"),
798            status: "closed".to_string(),
799            priority: 2,
800            ..Default::default()
801        }
802    }
803
804    // -- Empty graph --
805
806    #[test]
807    fn empty_graph_returns_empty_results() {
808        let graph = IssueGraph::build(&[]);
809        let metrics = graph.compute_metrics();
810        let result = compute_advanced_insights(&graph, &metrics);
811
812        assert!(result.top_k_set.items.is_empty());
813        assert_eq!(result.top_k_set.total_unlocked, 0);
814        assert!(result.coverage_set.items.is_empty());
815        assert!(result.k_paths.paths.is_empty());
816        assert!(result.cycle_break.suggestions.is_empty());
817        assert!(result.parallel_cut.cuts.is_empty());
818        assert!(result.parallel_gain.gains.is_empty());
819    }
820
821    // -- Single node --
822
823    #[test]
824    fn single_node_graph() {
825        let issues = vec![make_issue("A", &[])];
826        let graph = IssueGraph::build(&issues);
827        let metrics = graph.compute_metrics();
828        let result = compute_advanced_insights(&graph, &metrics);
829
830        // Single node: no downstream to unlock, no paths, no cycles.
831        assert!(result.top_k_set.items.len() <= 1);
832        assert!(result.k_paths.paths.is_empty());
833        assert!(result.cycle_break.suggestions.is_empty());
834        assert!(result.parallel_cut.cuts.is_empty());
835    }
836
837    // -- Linear chain: A -> B -> C -> D --
838
839    #[test]
840    fn linear_chain_top_k_identifies_root_blocker() {
841        let issues = vec![
842            make_issue("A", &[]),
843            make_issue("B", &["A"]),
844            make_issue("C", &["B"]),
845            make_issue("D", &["C"]),
846        ];
847        let graph = IssueGraph::build(&issues);
848        let metrics = graph.compute_metrics();
849        let result = compute_top_k_set(&graph, &metrics, 3);
850
851        // A blocks everything downstream, so it should be picked first.
852        assert!(!result.items.is_empty());
853        assert_eq!(result.items[0].id, "A");
854        assert!(result.items[0].marginal_unlocks >= 3);
855    }
856
857    #[test]
858    fn linear_chain_coverage_set() {
859        let issues = vec![
860            make_issue("A", &[]),
861            make_issue("B", &["A"]),
862            make_issue("C", &["B"]),
863            make_issue("D", &["C"]),
864        ];
865        let graph = IssueGraph::build(&issues);
866        let metrics = graph.compute_metrics();
867        let result = compute_coverage_set(&graph, &metrics);
868
869        // Should cover all critical-path edges with a small vertex set.
870        assert!(!result.items.is_empty());
871        assert!(result.items.len() <= 3); // Greedy cover of 3 edges needs at most 2-3 vertices.
872    }
873
874    #[test]
875    fn linear_chain_k_paths_returns_full_chain() {
876        let issues = vec![
877            make_issue("A", &[]),
878            make_issue("B", &["A"]),
879            make_issue("C", &["B"]),
880            make_issue("D", &["C"]),
881        ];
882        let graph = IssueGraph::build(&issues);
883        let metrics = graph.compute_metrics();
884        let result = compute_k_paths(&graph, &metrics, 5);
885
886        // Should find the full A -> B -> C -> D path.
887        assert!(!result.paths.is_empty());
888        let longest = &result.paths[0];
889        assert_eq!(longest.length, 4);
890        assert_eq!(longest.path, vec!["A", "B", "C", "D"]);
891    }
892
893    #[test]
894    fn linear_chain_parallel_cut() {
895        let issues = vec![
896            make_issue("A", &[]),
897            make_issue("B", &["A"]),
898            make_issue("C", &["B"]),
899            make_issue("D", &["C"]),
900        ];
901        let graph = IssueGraph::build(&issues);
902        let metrics = graph.compute_metrics();
903        let result = compute_parallel_cut(&graph, &metrics, 5);
904
905        // Should identify critical-path edges.
906        assert!(!result.cuts.is_empty());
907        // Depth: D=1, C=2, B=3, A=4 (root blocker has highest depth).
908        assert_eq!(result.current_serial_depth, 4);
909    }
910
911    // -- Diamond: A -> B, A -> C, B -> D, C -> D --
912
913    #[test]
914    fn diamond_graph_top_k() {
915        let issues = vec![
916            make_issue("A", &[]),
917            make_issue("B", &["A"]),
918            make_issue("C", &["A"]),
919            make_issue("D", &["B", "C"]),
920        ];
921        let graph = IssueGraph::build(&issues);
922        let metrics = graph.compute_metrics();
923        let result = compute_top_k_set(&graph, &metrics, 5);
924
925        // A unlocks B, C, and transitively D.
926        assert!(!result.items.is_empty());
927        assert_eq!(result.items[0].id, "A");
928    }
929
930    #[test]
931    fn diamond_parallel_gain_identifies_parallelizable_edges() {
932        let issues = vec![
933            make_issue("A", &[]),
934            make_issue("B", &["A"]),
935            make_issue("C", &["A"]),
936            make_issue("D", &["B", "C"]),
937        ];
938        let graph = IssueGraph::build(&issues);
939        let metrics = graph.compute_metrics();
940        let result = compute_parallel_gain(&graph, &metrics, 5);
941
942        // There should be critical-path edges to potentially cut.
943        assert!(result.current_max_chain >= 2);
944    }
945
946    // -- Cycle: A -> B -> C -> A --
947
948    #[test]
949    fn cycle_break_identifies_edges_to_remove() {
950        let issues = vec![
951            make_issue("A", &["C"]),
952            make_issue("B", &["A"]),
953            make_issue("C", &["B"]),
954        ];
955        let graph = IssueGraph::build(&issues);
956        let metrics = graph.compute_metrics();
957
958        assert!(!metrics.cycles.is_empty(), "expected cycles in graph");
959
960        let result = compute_cycle_break(&graph, &metrics, 5);
961        assert!(!result.suggestions.is_empty());
962        assert!(result.cycles_before > 0);
963        assert!(result.estimated_cycles_after < result.cycles_before);
964    }
965
966    // -- Closed issues are excluded --
967
968    #[test]
969    fn closed_issues_excluded_from_top_k() {
970        let issues = vec![
971            make_issue("A", &[]),
972            make_closed_issue("B"),
973            make_issue("C", &["A"]),
974        ];
975        let graph = IssueGraph::build(&issues);
976        let metrics = graph.compute_metrics();
977        let result = compute_top_k_set(&graph, &metrics, 5);
978
979        // B is closed, only A and C are open.
980        for item in &result.items {
981            assert_ne!(item.id, "B");
982        }
983    }
984
985    // -- No cycles --
986
987    #[test]
988    fn no_cycles_returns_empty_cycle_break() {
989        let issues = vec![make_issue("A", &[]), make_issue("B", &["A"])];
990        let graph = IssueGraph::build(&issues);
991        let metrics = graph.compute_metrics();
992        let result = compute_cycle_break(&graph, &metrics, 5);
993
994        assert!(result.suggestions.is_empty());
995        assert_eq!(result.cycles_before, 0);
996        assert_eq!(result.estimated_cycles_after, 0);
997    }
998
999    // -- Larger graph (12 nodes) --
1000
1001    #[test]
1002    fn larger_graph_all_algorithms_produce_results() {
1003        // Build a graph with multiple paths and a cycle.
1004        //
1005        // Epic1: E1 -> T1, T2, T3
1006        // Epic2: E2 -> T4, T5
1007        // T3 -> T6 -> T7
1008        // T5 -> T7 (cross-epic dep)
1009        // T7 -> T8 -> T9
1010        // Cycle: T10 -> T11 -> T12 -> T10
1011        let issues = vec![
1012            make_issue("E1", &[]),
1013            make_issue("E2", &[]),
1014            make_issue("T1", &["E1"]),
1015            make_issue("T2", &["E1"]),
1016            make_issue("T3", &["E1"]),
1017            make_issue("T4", &["E2"]),
1018            make_issue("T5", &["E2"]),
1019            make_issue("T6", &["T3"]),
1020            make_issue("T7", &["T6", "T5"]),
1021            make_issue("T8", &["T7"]),
1022            make_issue("T9", &["T8"]),
1023            make_issue("T10", &["T12"]),
1024            make_issue("T11", &["T10"]),
1025            make_issue("T12", &["T11"]),
1026        ];
1027        let graph = IssueGraph::build(&issues);
1028        let metrics = graph.compute_metrics();
1029        let result = compute_advanced_insights(&graph, &metrics);
1030
1031        // TopKSet should identify E1 and E2 as high-impact.
1032        assert!(!result.top_k_set.items.is_empty());
1033        assert!(result.top_k_set.total_unlocked >= 5);
1034
1035        // KPaths should find paths of length >= 4.
1036        assert!(!result.k_paths.paths.is_empty());
1037        assert!(result.k_paths.paths[0].length >= 4);
1038
1039        // CycleBreak should identify cycle T10-T11-T12.
1040        assert!(result.cycle_break.cycles_before > 0);
1041        assert!(!result.cycle_break.suggestions.is_empty());
1042
1043        // ParallelCut and ParallelGain depend on critical_depth which requires a DAG.
1044        // When cycles exist (T10-T11-T12), topological sort fails and critical_depth is all
1045        // zeros, so these legitimately return empty results.  Verify structural invariants
1046        // instead of requiring non-empty output.
1047        assert_eq!(
1048            result.parallel_cut.current_serial_depth,
1049            result.parallel_gain.current_max_chain
1050        );
1051    }
1052
1053    // -- Config customization --
1054
1055    #[test]
1056    fn custom_config_limits_results() {
1057        let issues = vec![
1058            make_issue("A", &[]),
1059            make_issue("B", &["A"]),
1060            make_issue("C", &["B"]),
1061            make_issue("D", &["C"]),
1062        ];
1063        let graph = IssueGraph::build(&issues);
1064        let metrics = graph.compute_metrics();
1065
1066        let config = AdvancedInsightsConfig {
1067            top_k: 1,
1068            k_paths_k: 1,
1069            max_cycle_break: 1,
1070            max_parallel_cut: 1,
1071        };
1072        let result = compute_advanced_insights_with_config(&graph, &metrics, &config);
1073
1074        assert!(result.top_k_set.items.len() <= 1);
1075        assert!(result.k_paths.paths.len() <= 1);
1076    }
1077
1078    // -- bfs_downstream ---
1079
1080    #[test]
1081    fn bfs_downstream_no_dependents() {
1082        let issues = vec![make_issue("A", &[])];
1083        let graph = IssueGraph::build(&issues);
1084        let downstream = bfs_downstream(&graph, "A");
1085        assert!(downstream.is_empty());
1086    }
1087
1088    #[test]
1089    fn bfs_downstream_transitive() {
1090        let issues = vec![
1091            make_issue("A", &[]),
1092            make_issue("B", &["A"]),
1093            make_issue("C", &["B"]),
1094        ];
1095        let graph = IssueGraph::build(&issues);
1096        let downstream = bfs_downstream(&graph, "A");
1097        assert!(downstream.contains("B"));
1098        assert!(downstream.contains("C"));
1099    }
1100
1101    // -- TopKSet edge cases --
1102
1103    #[test]
1104    fn top_k_set_all_closed_returns_empty() {
1105        let issues = vec![make_closed_issue("A"), make_closed_issue("B")];
1106        let graph = IssueGraph::build(&issues);
1107        let metrics = graph.compute_metrics();
1108        let result = compute_top_k_set(&graph, &metrics, 5);
1109        assert!(result.items.is_empty());
1110        assert_eq!(result.total_unlocked, 0);
1111    }
1112
1113    #[test]
1114    fn top_k_set_independent_issues_stops_after_first() {
1115        // Issues with no deps and no dependents: marginal=0 after first pick
1116        let issues = vec![
1117            make_issue("A", &[]),
1118            make_issue("B", &[]),
1119            make_issue("C", &[]),
1120        ];
1121        let graph = IssueGraph::build(&issues);
1122        let metrics = graph.compute_metrics();
1123        let result = compute_top_k_set(&graph, &metrics, 10);
1124        // First pick has marginal=0 but is selected; second marginal=0 should stop
1125        assert_eq!(result.items.len(), 1);
1126        assert_eq!(result.total_unlocked, 0);
1127        assert_eq!(result.items[0].cumulative_unlocks, 0);
1128    }
1129
1130    // -- CoverageSet edge cases --
1131
1132    #[test]
1133    fn coverage_set_no_critical_paths() {
1134        let issues = vec![make_issue("A", &[]), make_issue("B", &[])];
1135        let graph = IssueGraph::build(&issues);
1136        let metrics = graph.compute_metrics();
1137        let result = compute_coverage_set(&graph, &metrics);
1138        // Independent nodes have critical_depth 0, so no critical edges
1139        assert!(result.items.is_empty());
1140        assert_eq!(result.total_paths, 0);
1141    }
1142
1143    // -- KPaths edge cases --
1144
1145    #[test]
1146    fn k_paths_all_closed_returns_empty() {
1147        let issues = vec![make_closed_issue("A"), make_closed_issue("B")];
1148        let graph = IssueGraph::build(&issues);
1149        let metrics = graph.compute_metrics();
1150        let result = compute_k_paths(&graph, &metrics, 5);
1151        assert!(result.paths.is_empty());
1152    }
1153
1154    #[test]
1155    fn k_paths_single_edge_returns_path_of_length_2() {
1156        let issues = vec![make_issue("A", &[]), make_issue("B", &["A"])];
1157        let graph = IssueGraph::build(&issues);
1158        let metrics = graph.compute_metrics();
1159        let result = compute_k_paths(&graph, &metrics, 5);
1160        assert!(!result.paths.is_empty());
1161        assert_eq!(result.paths[0].length, 2);
1162    }
1163
1164    // -- ParallelCut edge cases --
1165
1166    #[test]
1167    fn parallel_cut_no_deps_returns_empty_cuts() {
1168        let issues = vec![make_issue("A", &[]), make_issue("B", &[])];
1169        let graph = IssueGraph::build(&issues);
1170        let metrics = graph.compute_metrics();
1171        let result = compute_parallel_cut(&graph, &metrics, 5);
1172        // No dependency edges means no critical-path edges to cut
1173        assert!(result.cuts.is_empty());
1174    }
1175
1176    // -- ParallelGain edge cases --
1177
1178    #[test]
1179    fn parallel_gain_no_open_returns_empty() {
1180        let issues = vec![make_closed_issue("A"), make_closed_issue("B")];
1181        let graph = IssueGraph::build(&issues);
1182        let metrics = graph.compute_metrics();
1183        let result = compute_parallel_gain(&graph, &metrics, 5);
1184        assert!(result.gains.is_empty());
1185    }
1186
1187    #[test]
1188    fn parallel_gain_two_independent_chains() {
1189        let issues = vec![
1190            make_issue("A", &[]),
1191            make_issue("B", &["A"]),
1192            make_issue("C", &[]),
1193            make_issue("D", &["C"]),
1194        ];
1195        let graph = IssueGraph::build(&issues);
1196        let metrics = graph.compute_metrics();
1197        let result = compute_parallel_gain(&graph, &metrics, 5);
1198        assert!(result.current_components >= 2);
1199    }
1200
1201    // -- Config default values --
1202
1203    #[test]
1204    fn default_config_values() {
1205        let config = AdvancedInsightsConfig::default();
1206        assert_eq!(config.top_k, 10);
1207        assert_eq!(config.k_paths_k, 5);
1208        assert_eq!(config.max_cycle_break, 10);
1209        assert_eq!(config.max_parallel_cut, 10);
1210    }
1211
1212    // -- Usage hints --
1213
1214    #[test]
1215    fn usage_hints_populated() {
1216        let graph = IssueGraph::build(&[]);
1217        let metrics = graph.compute_metrics();
1218        let result = compute_advanced_insights(&graph, &metrics);
1219        assert!(!result.usage_hints.is_empty());
1220        assert!(result.usage_hints.iter().any(|h| h.contains("jq")));
1221    }
1222
1223    // -- simulate_edge_removal --
1224
1225    #[test]
1226    fn simulate_edge_removal_splits_components() {
1227        // A -> B: removing this edge should split into 2 components
1228        let issues = vec![make_issue("A", &[]), make_issue("B", &["A"])];
1229        let graph = IssueGraph::build(&issues);
1230        let metrics = graph.compute_metrics();
1231        let open_ids: HashSet<String> = issues.iter().map(|i| i.id.clone()).collect();
1232
1233        let (components, max_chain) = simulate_edge_removal(&graph, &open_ids, &metrics, "A", "B");
1234        assert_eq!(components, 2);
1235        assert_eq!(max_chain, 0); // No edges remain, so max chain is 0
1236    }
1237
1238    // -- Large acyclic graph: parallel_cut and parallel_gain require DAG --
1239
1240    #[test]
1241    fn acyclic_graph_parallel_cut_and_gain() {
1242        // Same structure as larger_graph minus the cycle nodes.
1243        // E1 -> T1, T2, T3; E2 -> T4, T5; T3 -> T6 -> T7; T5 -> T7; T7 -> T8 -> T9
1244        let issues = vec![
1245            make_issue("E1", &[]),
1246            make_issue("E2", &[]),
1247            make_issue("T1", &["E1"]),
1248            make_issue("T2", &["E1"]),
1249            make_issue("T3", &["E1"]),
1250            make_issue("T4", &["E2"]),
1251            make_issue("T5", &["E2"]),
1252            make_issue("T6", &["T3"]),
1253            make_issue("T7", &["T6", "T5"]),
1254            make_issue("T8", &["T7"]),
1255            make_issue("T9", &["T8"]),
1256        ];
1257        let graph = IssueGraph::build(&issues);
1258        let metrics = graph.compute_metrics();
1259        let result = compute_advanced_insights(&graph, &metrics);
1260
1261        // DAG with no cycles: critical_depth is computed properly.
1262        assert!(!result.parallel_cut.cuts.is_empty());
1263        // Longest chain: E1 -> T3 -> T6 -> T7 -> T8 -> T9 = depth 6.
1264        assert!(result.parallel_cut.current_serial_depth >= 5);
1265        assert!(!result.parallel_gain.gains.is_empty());
1266        assert!(result.parallel_gain.current_max_chain >= 5);
1267    }
1268}