1use std::collections::{HashMap, HashSet, VecDeque};
6
7use serde::Serialize;
8
9use crate::analysis::graph::{GraphMetrics, IssueGraph};
10use crate::model::Issue;
11
12#[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#[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#[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#[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 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 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 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
143fn 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(¤t) {
151 if visited.insert(dep.clone()) {
152 queue.push_back(dep);
153 }
154 }
155 }
156 visited
157}
158
159#[derive(Debug, Clone, Serialize)]
164pub struct CoverageSetResult {
165 pub items: Vec<String>,
166 pub paths_covered: usize,
167 pub total_paths: usize,
168}
169
170fn compute_coverage_set(graph: &IssueGraph, metrics: &GraphMetrics) -> CoverageSetResult {
175 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 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 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 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#[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
244fn 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 let sources: Vec<String> = open_ids
264 .iter()
265 .filter(|id| graph.open_blockers(id).is_empty())
266 .cloned()
267 .collect();
268
269 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(¤t)
280 .into_iter()
281 .filter(|d| open_ids.contains(d) && !path.contains(d))
282 .collect();
283
284 if deps.is_empty() {
285 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 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#[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
350fn 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 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 let mut edge_freq: HashMap<(String, String), usize> = HashMap::new();
381 for cycle in &remaining_cycles {
382 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 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 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#[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
457fn 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 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 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 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#[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
544fn 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 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 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
624fn 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 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; }
644 adj.entry(blocker.clone()).or_default().push(id.clone());
645 rev_adj.entry(id.clone()).or_default().push(blocker);
646 }
647 }
648
649 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(¤t).into_iter().flatten() {
664 if !visited.contains(neighbor) {
665 queue.push_back(neighbor.clone());
666 }
667 }
668 for neighbor in rev_adj.get(¤t).into_iter().flatten() {
669 if !visited.contains(neighbor) {
670 queue.push_back(neighbor.clone());
671 }
672 }
673 }
674 }
675
676 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(¤t).copied().unwrap_or(0);
700 for neighbor in adj.get(¤t).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#[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#[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#[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 #[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 #[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 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 #[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 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 assert!(!result.items.is_empty());
871 assert!(result.items.len() <= 3); }
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 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 assert!(!result.cuts.is_empty());
907 assert_eq!(result.current_serial_depth, 4);
909 }
910
911 #[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 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 assert!(result.current_max_chain >= 2);
944 }
945
946 #[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 #[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 for item in &result.items {
981 assert_ne!(item.id, "B");
982 }
983 }
984
985 #[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 #[test]
1002 fn larger_graph_all_algorithms_produce_results() {
1003 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 assert!(!result.top_k_set.items.is_empty());
1033 assert!(result.top_k_set.total_unlocked >= 5);
1034
1035 assert!(!result.k_paths.paths.is_empty());
1037 assert!(result.k_paths.paths[0].length >= 4);
1038
1039 assert!(result.cycle_break.cycles_before > 0);
1041 assert!(!result.cycle_break.suggestions.is_empty());
1042
1043 assert_eq!(
1048 result.parallel_cut.current_serial_depth,
1049 result.parallel_gain.current_max_chain
1050 );
1051 }
1052
1053 #[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 #[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 #[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 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 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 #[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 assert!(result.items.is_empty());
1140 assert_eq!(result.total_paths, 0);
1141 }
1142
1143 #[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 #[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 assert!(result.cuts.is_empty());
1174 }
1175
1176 #[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 #[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 #[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 #[test]
1226 fn simulate_edge_removal_splits_components() {
1227 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); }
1237
1238 #[test]
1241 fn acyclic_graph_parallel_cut_and_gain() {
1242 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 assert!(!result.parallel_cut.cuts.is_empty());
1263 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}