1use std::cmp::Reverse;
2use std::collections::{BinaryHeap, HashMap, HashSet};
3
4use petgraph::algo::kosaraju_scc;
5use petgraph::graph::DiGraph;
6use petgraph::visit::EdgeRef;
7use serde::{Deserialize, Serialize};
8
9use crate::model::Issue;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AnalysisConfig {
18 #[serde(rename = "ComputePageRank")]
19 pub enable_pagerank: bool,
20 #[serde(rename = "ComputeBetweenness")]
21 pub enable_betweenness: bool,
22 #[serde(rename = "ComputeEigenvector")]
23 pub enable_eigenvector: bool,
24 #[serde(rename = "ComputeHITS")]
25 pub enable_hits: bool,
26 #[serde(rename = "ComputeCycles")]
27 pub enable_cycles: bool,
28 #[serde(rename = "ComputeCriticalPath")]
29 pub enable_critical_path: bool,
30 #[serde(rename = "ComputeKCore")]
31 pub enable_k_core: bool,
32 #[serde(rename = "ComputeArticulation")]
33 pub enable_articulation: bool,
34 #[serde(rename = "ComputeSlack")]
35 pub enable_slack: bool,
36
37 #[serde(rename = "BetweennessSampleSize")]
39 pub betweenness_max_nodes: usize,
40 pub eigenvector_max_nodes: usize,
42
43 #[serde(rename = "BetweennessIsApproximate")]
45 pub betweenness_is_approximate: bool,
46 #[serde(rename = "BetweennessMode")]
47 pub betweenness_mode: &'static str,
48 #[serde(rename = "BetweennessSkipReason")]
49 pub betweenness_skip_reason: &'static str,
50 #[serde(rename = "BetweennessTimeout")]
51 pub betweenness_timeout_ns: u64,
52 #[serde(rename = "PageRankSkipReason")]
53 pub pagerank_skip_reason: &'static str,
54 #[serde(rename = "PageRankTimeout")]
55 pub pagerank_timeout_ns: u64,
56 #[serde(rename = "HITSSkipReason")]
57 pub hits_skip_reason: &'static str,
58 #[serde(rename = "HITSTimeout")]
59 pub hits_timeout_ns: u64,
60 #[serde(rename = "CyclesSkipReason")]
61 pub cycles_skip_reason: &'static str,
62 #[serde(rename = "CyclesTimeout")]
63 pub cycles_timeout_ns: u64,
64 #[serde(rename = "MaxCyclesToStore")]
65 pub max_cycles_to_store: usize,
66}
67
68impl Default for AnalysisConfig {
69 fn default() -> Self {
70 Self::full()
71 }
72}
73
74impl AnalysisConfig {
75 #[must_use]
77 pub const fn full() -> Self {
78 Self {
79 enable_pagerank: true,
80 enable_betweenness: true,
81 enable_eigenvector: true,
82 enable_hits: true,
83 enable_cycles: true,
84 enable_critical_path: true,
85 enable_k_core: true,
86 enable_articulation: true,
87 enable_slack: true,
88 betweenness_max_nodes: 10_000,
89 eigenvector_max_nodes: 10_000,
90 betweenness_is_approximate: false,
91 betweenness_mode: "exact",
92 betweenness_skip_reason: "",
93 betweenness_timeout_ns: 2_000_000_000,
94 pagerank_skip_reason: "",
95 pagerank_timeout_ns: 2_000_000_000,
96 hits_skip_reason: "",
97 hits_timeout_ns: 2_000_000_000,
98 cycles_skip_reason: "",
99 cycles_timeout_ns: 2_000_000_000,
100 max_cycles_to_store: 100,
101 }
102 }
103
104 #[must_use]
106 pub const fn for_size(node_count: usize) -> Self {
107 Self {
108 enable_pagerank: true,
109 enable_betweenness: node_count <= 10_000,
110 enable_eigenvector: node_count <= 10_000,
111 enable_hits: node_count <= 50_000,
112 enable_cycles: true,
113 enable_critical_path: true,
114 enable_k_core: true,
115 enable_articulation: true,
116 enable_slack: true,
117 betweenness_max_nodes: 10_000,
118 eigenvector_max_nodes: 10_000,
119 betweenness_is_approximate: false,
120 betweenness_mode: "exact",
121 betweenness_skip_reason: "",
122 betweenness_timeout_ns: 2_000_000_000,
123 pagerank_skip_reason: "",
124 pagerank_timeout_ns: 2_000_000_000,
125 hits_skip_reason: "",
126 hits_timeout_ns: 2_000_000_000,
127 cycles_skip_reason: "",
128 cycles_timeout_ns: 2_000_000_000,
129 max_cycles_to_store: 100,
130 }
131 }
132
133 #[must_use]
135 pub const fn triage_only() -> Self {
136 Self {
137 enable_pagerank: true,
138 enable_betweenness: true,
139 enable_eigenvector: false,
140 enable_hits: false,
141 enable_cycles: true,
142 enable_critical_path: true,
143 enable_k_core: false,
144 enable_articulation: false,
145 enable_slack: false,
146 betweenness_max_nodes: 10_000,
147 eigenvector_max_nodes: 10_000,
148 betweenness_is_approximate: false,
149 betweenness_mode: "exact",
150 betweenness_skip_reason: "",
151 betweenness_timeout_ns: 2_000_000_000,
152 pagerank_skip_reason: "",
153 pagerank_timeout_ns: 2_000_000_000,
154 hits_skip_reason: "",
155 hits_timeout_ns: 2_000_000_000,
156 cycles_skip_reason: "",
157 cycles_timeout_ns: 2_000_000_000,
158 max_cycles_to_store: 100,
159 }
160 }
161
162 #[must_use]
167 pub const fn triage_runtime() -> Self {
168 Self {
169 enable_pagerank: true,
170 enable_betweenness: true,
171 enable_eigenvector: false,
172 enable_hits: false,
173 enable_cycles: true,
174 enable_critical_path: true,
175 enable_k_core: false,
176 enable_articulation: true,
177 enable_slack: false,
178 betweenness_max_nodes: 10_000,
179 eigenvector_max_nodes: 10_000,
180 betweenness_is_approximate: false,
181 betweenness_mode: "exact",
182 betweenness_skip_reason: "",
183 betweenness_timeout_ns: 2_000_000_000,
184 pagerank_skip_reason: "",
185 pagerank_timeout_ns: 2_000_000_000,
186 hits_skip_reason: "",
187 hits_timeout_ns: 2_000_000_000,
188 cycles_skip_reason: "",
189 cycles_timeout_ns: 2_000_000_000,
190 max_cycles_to_store: 100,
191 }
192 }
193
194 #[must_use]
199 pub const fn fast_phase() -> Self {
200 Self {
201 enable_pagerank: true,
202 enable_betweenness: false,
203 enable_eigenvector: false,
204 enable_hits: false,
205 enable_cycles: true,
206 enable_critical_path: true,
207 enable_k_core: true,
208 enable_articulation: true,
209 enable_slack: true,
210 betweenness_max_nodes: 10_000,
211 eigenvector_max_nodes: 10_000,
212 betweenness_is_approximate: false,
213 betweenness_mode: "exact",
214 betweenness_skip_reason: "",
215 betweenness_timeout_ns: 2_000_000_000,
216 pagerank_skip_reason: "",
217 pagerank_timeout_ns: 2_000_000_000,
218 hits_skip_reason: "",
219 hits_timeout_ns: 2_000_000_000,
220 cycles_skip_reason: "",
221 cycles_timeout_ns: 2_000_000_000,
222 max_cycles_to_store: 100,
223 }
224 }
225
226 #[must_use]
228 pub const fn slow_phase() -> Self {
229 Self {
230 enable_pagerank: false,
231 enable_betweenness: true,
232 enable_eigenvector: true,
233 enable_hits: true,
234 enable_cycles: false,
235 enable_critical_path: false,
236 enable_k_core: false,
237 enable_articulation: false,
238 enable_slack: false,
239 betweenness_max_nodes: 10_000,
240 eigenvector_max_nodes: 10_000,
241 betweenness_is_approximate: false,
242 betweenness_mode: "exact",
243 betweenness_skip_reason: "",
244 betweenness_timeout_ns: 2_000_000_000,
245 pagerank_skip_reason: "",
246 pagerank_timeout_ns: 2_000_000_000,
247 hits_skip_reason: "",
248 hits_timeout_ns: 2_000_000_000,
249 cycles_skip_reason: "",
250 cycles_timeout_ns: 2_000_000_000,
251 max_cycles_to_store: 100,
252 }
253 }
254
255 pub const BACKGROUND_THRESHOLD: usize = 200;
257
258 #[must_use]
261 pub fn background_threshold() -> usize {
262 std::env::var("BVR_BACKGROUND_THRESHOLD")
263 .ok()
264 .and_then(|v| v.trim().parse().ok())
265 .unwrap_or(Self::BACKGROUND_THRESHOLD)
266 }
267}
268
269#[derive(Debug, Clone, Serialize)]
271pub struct SkippedMetric {
272 pub metric: &'static str,
273 pub reason: String,
274}
275
276#[derive(Debug, Clone)]
277pub struct IssueGraph {
278 graph: DiGraph<(), ()>,
279 pub(crate) node_to_id: Vec<String>,
280 pub(crate) id_to_index: HashMap<String, usize>,
281 pub(crate) issues: Vec<Issue>,
282 blockers_by_issue: HashMap<String, Vec<String>>,
283 dependents_by_issue: HashMap<String, Vec<String>>,
284 successors: Vec<Vec<usize>>, predecessors: Vec<Vec<usize>>, edge_count: usize,
287}
288
289#[derive(Debug, Clone)]
290pub struct GraphMetrics {
291 pub pagerank: HashMap<String, f64>,
292 pub betweenness: HashMap<String, f64>,
293 pub eigenvector: HashMap<String, f64>,
294 pub hubs: HashMap<String, f64>,
295 pub authorities: HashMap<String, f64>,
296 pub blocks_count: HashMap<String, usize>,
297 pub blocked_by_count: HashMap<String, usize>,
298 pub critical_depth: HashMap<String, usize>,
299 pub k_core: HashMap<String, u32>,
300 pub articulation_points: HashSet<String>,
301 pub slack: HashMap<String, f64>,
302 pub cycles: Vec<Vec<String>>,
303 pub skipped_metrics: Vec<SkippedMetric>,
304 pub config: AnalysisConfig,
305}
306
307impl GraphMetrics {
308 pub fn merge_slow(&mut self, slow: GraphMetrics) {
313 if !slow.betweenness.is_empty() {
314 self.betweenness = slow.betweenness;
315 self.skipped_metrics.retain(|s| s.metric != "Betweenness");
316 }
317 if !slow.eigenvector.is_empty() {
318 self.eigenvector = slow.eigenvector;
319 self.skipped_metrics.retain(|s| s.metric != "Eigenvector");
320 }
321 if !slow.hubs.is_empty() {
322 self.hubs = slow.hubs;
323 self.authorities = slow.authorities;
324 self.skipped_metrics.retain(|s| s.metric != "HITS");
325 }
326 self.skipped_metrics.extend(slow.skipped_metrics);
328 self.config = AnalysisConfig::full();
329 }
330
331 #[must_use]
333 pub fn has_pending_slow_metrics(&self) -> bool {
334 self.skipped_metrics.iter().any(|s| {
335 matches!(s.metric, "Betweenness" | "Eigenvector" | "HITS")
336 && s.reason.contains("disabled by config")
337 })
338 }
339}
340
341struct BetweennessScratch {
342 stack: Vec<usize>,
343 pred: Vec<Vec<usize>>,
344 sigma: Vec<f64>,
345 dist: Vec<i32>,
346 delta: Vec<f64>,
347 queue: Vec<usize>,
348}
349
350impl BetweennessScratch {
351 fn new(node_count: usize) -> Self {
352 Self {
353 stack: Vec::with_capacity(node_count),
354 pred: (0..node_count)
355 .map(|_| Vec::with_capacity(4))
356 .collect::<Vec<_>>(),
357 sigma: vec![0.0; node_count],
358 dist: vec![-1; node_count],
359 delta: vec![0.0; node_count],
360 queue: Vec::with_capacity(node_count),
361 }
362 }
363
364 fn reset(&mut self) {
365 self.stack.clear();
366 self.queue.clear();
367 self.sigma.fill(0.0);
368 self.dist.fill(-1);
369 self.delta.fill(0.0);
370 for preds in &mut self.pred {
371 preds.clear();
372 }
373 }
374}
375
376impl IssueGraph {
377 #[must_use]
378 pub fn build(issues: &[Issue]) -> Self {
379 let mut graph = DiGraph::<(), ()>::new();
380 let mut node_indices = Vec::with_capacity(issues.len());
381 let mut node_to_id = Vec::with_capacity(issues.len());
382 let mut id_to_index = HashMap::with_capacity(issues.len());
383
384 for (index, issue) in issues.iter().enumerate() {
385 let node = graph.add_node(());
386 node_indices.push(node);
387 node_to_id.push(issue.id.clone());
388 id_to_index.insert(issue.id.clone(), index);
389 }
390
391 let issue_vec: Vec<Issue> = issues.to_vec();
392
393 let mut blockers_by_issue: HashMap<String, Vec<String>> = HashMap::new();
394 let mut dependents_by_issue: HashMap<String, Vec<String>> = HashMap::new();
395 let mut successors = vec![Vec::<usize>::new(); issues.len()];
396 let mut predecessors = vec![Vec::<usize>::new(); issues.len()];
397 let mut seen = HashSet::<(usize, usize)>::new();
398 let mut edge_count = 0usize;
399
400 for issue in issues {
401 let Some(&source_index) = id_to_index.get(&issue.id) else {
402 continue;
403 };
404
405 for dep in &issue.dependencies {
406 if !dep.is_blocking() || dep.depends_on_id.trim().is_empty() {
407 continue;
408 }
409
410 let Some(&target_index) = id_to_index.get(&dep.depends_on_id) else {
411 continue;
412 };
413
414 if !seen.insert((source_index, target_index)) {
415 continue;
416 }
417
418 graph.add_edge(node_indices[source_index], node_indices[target_index], ());
419 successors[source_index].push(target_index);
420 predecessors[target_index].push(source_index);
421
422 blockers_by_issue
423 .entry(issue.id.clone())
424 .or_default()
425 .push(dep.depends_on_id.clone());
426 dependents_by_issue
427 .entry(dep.depends_on_id.clone())
428 .or_default()
429 .push(issue.id.clone());
430
431 edge_count = edge_count.saturating_add(1);
432 }
433 }
434
435 for neighbors in &mut successors {
436 neighbors.sort_unstable();
437 }
438 for neighbors in &mut predecessors {
439 neighbors.sort_unstable();
440 }
441 for blockers in blockers_by_issue.values_mut() {
442 blockers.sort();
443 blockers.dedup();
444 }
445 for dependents in dependents_by_issue.values_mut() {
446 dependents.sort();
447 dependents.dedup();
448 }
449
450 Self {
451 graph,
452 node_to_id,
453 id_to_index,
454 issues: issue_vec,
455 blockers_by_issue,
456 dependents_by_issue,
457 successors,
458 predecessors,
459 edge_count,
460 }
461 }
462
463 #[must_use]
464 pub fn issue(&self, id: &str) -> Option<&Issue> {
465 self.id_to_index.get(id).map(|&i| &self.issues[i])
466 }
467
468 #[must_use]
469 pub fn issue_ids_sorted(&self) -> Vec<String> {
470 let mut ids = self.node_to_id.clone();
473 ids.sort();
474 ids
475 }
476
477 #[must_use]
478 pub fn node_count(&self) -> usize {
479 self.node_to_id.len()
480 }
481
482 #[must_use]
483 pub const fn edge_count(&self) -> usize {
484 self.edge_count
485 }
486
487 #[must_use]
488 pub fn blockers(&self, issue_id: &str) -> Vec<String> {
489 self.blockers_by_issue
490 .get(issue_id)
491 .cloned()
492 .unwrap_or_default()
493 }
494
495 #[must_use]
496 pub fn dependents(&self, issue_id: &str) -> Vec<String> {
497 self.dependents_by_issue
498 .get(issue_id)
499 .cloned()
500 .unwrap_or_default()
501 }
502
503 #[must_use]
504 pub fn open_blockers(&self, issue_id: &str) -> Vec<String> {
505 self.blockers(issue_id)
506 .into_iter()
507 .filter(|blocker_id| self.issue(blocker_id).is_some_and(Issue::is_open_like))
508 .collect()
509 }
510
511 #[must_use]
512 pub fn actionable_ids(&self) -> Vec<String> {
513 let mut directly_blocked = HashSet::<String>::new();
515 for issue in &self.issues {
516 let id = &issue.id;
517 if issue.is_closed_like() {
518 continue;
519 }
520 if !self.open_blockers(id).is_empty() {
521 directly_blocked.insert(id.clone());
522 }
523 }
524
525 let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
529 for issue in &self.issues {
530 for dep in &issue.dependencies {
531 if dep.is_parent_child()
532 && !dep.depends_on_id.trim().is_empty()
533 && self.id_to_index.contains_key(&dep.depends_on_id)
534 {
535 children_of
536 .entry(dep.depends_on_id.clone())
537 .or_default()
538 .push(issue.id.clone());
539 }
540 }
541 }
542
543 let mut blocked = directly_blocked.clone();
546 let max_depth = 50;
547 for _ in 0..max_depth {
548 let mut newly_blocked = Vec::<String>::new();
549 for parent_id in &blocked {
550 if let Some(children) = children_of.get(parent_id) {
551 for child_id in children {
552 if !blocked.contains(child_id)
553 && self
554 .issue(child_id)
555 .is_some_and(|issue| !issue.is_closed_like())
556 {
557 newly_blocked.push(child_id.clone());
558 }
559 }
560 }
561 }
562 if newly_blocked.is_empty() {
563 break;
564 }
565 for id in newly_blocked {
566 blocked.insert(id);
567 }
568 }
569
570 let mut ids = self.issue_ids_sorted();
572 ids.retain(|id| self.issue(id).is_some_and(Issue::is_open_like) && !blocked.contains(id));
573 ids
574 }
575
576 #[must_use]
577 pub fn connected_open_components(&self) -> Vec<Vec<String>> {
578 let open_ids: HashSet<String> = self
579 .issues
580 .iter()
581 .filter(|issue| issue.is_open_like())
582 .map(|issue| issue.id.clone())
583 .collect();
584
585 let mut seen = HashSet::<String>::new();
586 let mut components = Vec::<Vec<String>>::new();
587
588 for start_id in &open_ids {
589 if seen.contains(start_id) {
590 continue;
591 }
592
593 let mut stack = vec![start_id.clone()];
594 let mut component = Vec::<String>::new();
595 seen.insert(start_id.clone());
596
597 while let Some(id) = stack.pop() {
598 component.push(id.clone());
599
600 let neighbors = self.blockers(&id).into_iter().chain(self.dependents(&id));
601
602 for neighbor in neighbors {
603 if !open_ids.contains(&neighbor) {
604 continue;
605 }
606 if seen.insert(neighbor.clone()) {
607 stack.push(neighbor);
608 }
609 }
610 }
611
612 component.sort();
613 components.push(component);
614 }
615
616 components.sort_by(|a, b| a.first().cmp(&b.first()));
617 components
618 }
619
620 #[must_use]
622 pub fn compute_metrics(&self) -> GraphMetrics {
623 self.compute_metrics_with_config(&AnalysisConfig::default())
624 }
625
626 #[must_use]
628 pub fn compute_metrics_with_config(&self, config: &AnalysisConfig) -> GraphMetrics {
629 let n = self.node_count();
630 let mut skipped = Vec::<SkippedMetric>::new();
631
632 let pagerank = if config.enable_pagerank {
633 self.compute_pagerank()
634 } else {
635 skipped.push(SkippedMetric {
636 metric: "PageRank",
637 reason: "disabled by config".to_string(),
638 });
639 HashMap::new()
640 };
641
642 let betweenness = if config.enable_betweenness && n <= config.betweenness_max_nodes {
643 self.compute_betweenness()
644 } else {
645 let reason = if !config.enable_betweenness {
646 "disabled by config".to_string()
647 } else {
648 format!(
649 "graph too large ({n} nodes > {} max)",
650 config.betweenness_max_nodes
651 )
652 };
653 skipped.push(SkippedMetric {
654 metric: "Betweenness",
655 reason,
656 });
657 HashMap::new()
658 };
659
660 let eigenvector = if config.enable_eigenvector && n <= config.eigenvector_max_nodes {
661 self.compute_eigenvector()
662 } else {
663 let reason = if !config.enable_eigenvector {
664 "disabled by config".to_string()
665 } else {
666 format!(
667 "graph too large ({n} nodes > {} max)",
668 config.eigenvector_max_nodes
669 )
670 };
671 skipped.push(SkippedMetric {
672 metric: "Eigenvector",
673 reason,
674 });
675 HashMap::new()
676 };
677
678 let (hubs, authorities) = if config.enable_hits {
679 self.compute_hits()
680 } else {
681 skipped.push(SkippedMetric {
682 metric: "HITS",
683 reason: "disabled by config".to_string(),
684 });
685 (HashMap::new(), HashMap::new())
686 };
687
688 let mut blocks_count = HashMap::with_capacity(self.issues.len());
690 let mut blocked_by_count = HashMap::with_capacity(self.issues.len());
691 for id in self.issue_ids_sorted() {
692 blocks_count.insert(
693 id.clone(),
694 self.dependents_by_issue.get(&id).map_or(0, Vec::len),
695 );
696 blocked_by_count.insert(
697 id.clone(),
698 self.blockers_by_issue.get(&id).map_or(0, Vec::len),
699 );
700 }
701
702 let critical_depth = if config.enable_critical_path {
703 self.compute_critical_depth()
704 } else {
705 skipped.push(SkippedMetric {
706 metric: "CriticalPath",
707 reason: "disabled by config".to_string(),
708 });
709 HashMap::new()
710 };
711
712 let k_core = if config.enable_k_core {
713 self.compute_k_core()
714 } else {
715 skipped.push(SkippedMetric {
716 metric: "KCore",
717 reason: "disabled by config".to_string(),
718 });
719 HashMap::new()
720 };
721
722 let articulation_points = if config.enable_articulation {
723 self.compute_articulation_points()
724 } else {
725 skipped.push(SkippedMetric {
726 metric: "Articulation",
727 reason: "disabled by config".to_string(),
728 });
729 HashSet::new()
730 };
731
732 let slack = if config.enable_slack {
733 self.compute_slack()
734 } else {
735 skipped.push(SkippedMetric {
736 metric: "Slack",
737 reason: "disabled by config".to_string(),
738 });
739 HashMap::new()
740 };
741
742 let cycles = if config.enable_cycles {
743 self.find_cycles()
744 } else {
745 skipped.push(SkippedMetric {
746 metric: "Cycles",
747 reason: "disabled by config".to_string(),
748 });
749 Vec::new()
750 };
751
752 GraphMetrics {
753 pagerank,
754 betweenness,
755 eigenvector,
756 hubs,
757 authorities,
758 blocks_count,
759 blocked_by_count,
760 critical_depth,
761 k_core,
762 articulation_points,
763 slack,
764 cycles,
765 skipped_metrics: skipped,
766 config: config.clone(),
767 }
768 }
769
770 fn compute_pagerank(&self) -> HashMap<String, f64> {
771 let node_count = self.node_to_id.len();
772 if node_count == 0 {
773 return HashMap::new();
774 }
775
776 let damping = 0.85_f64;
777 let base = (1.0_f64 - damping) / node_count as f64;
778 let mut ranks = vec![1.0_f64 / node_count as f64; node_count];
779
780 for _ in 0..100 {
781 let mut next = vec![base; node_count];
782
783 let dangling_sum = (0..node_count)
784 .filter(|&node| self.successors[node].is_empty())
785 .map(|node| ranks[node])
786 .sum::<f64>();
787 let dangling_contrib = damping * dangling_sum / node_count as f64;
788 for value in &mut next {
789 *value += dangling_contrib;
790 }
791
792 for (node, rank) in ranks.iter().enumerate().take(node_count) {
793 let out_degree = self.successors[node].len();
794 if out_degree == 0 {
795 continue;
796 }
797
798 let share = *rank / out_degree as f64;
799 for &target in &self.successors[node] {
800 next[target] += damping * share;
801 }
802 }
803
804 let delta = ranks
805 .iter()
806 .zip(next.iter())
807 .map(|(a, b)| (a - b).abs())
808 .sum::<f64>();
809
810 ranks = next;
811 if delta < 1e-9 {
812 break;
813 }
814 }
815
816 self.map_from_f64_scores(&ranks)
817 }
818
819 fn compute_betweenness(&self) -> HashMap<String, f64> {
820 let n = self.node_to_id.len();
821 if n == 0 {
822 return HashMap::new();
823 }
824
825 if n > 512 {
826 let sample_size = (n / 5).clamp(128, 512);
827 return self.compute_betweenness_sampled(sample_size);
828 }
829
830 let mut bc = vec![0.0_f64; n];
831 let mut scratch = BetweennessScratch::new(n);
832 for source in 0..n {
833 self.single_source_betweenness(source, &mut bc, &mut scratch);
834 }
835
836 self.map_from_f64_scores(&bc)
837 }
838
839 fn compute_betweenness_sampled(&self, sample_size: usize) -> HashMap<String, f64> {
840 let n = self.node_to_id.len();
841 if n == 0 {
842 return HashMap::new();
843 }
844
845 let pivot_count = sample_size.min(n);
846 let mut pivots = Vec::<usize>::with_capacity(pivot_count);
847 let mut used = HashSet::<usize>::with_capacity(pivot_count);
848 let step = (n / pivot_count.max(1)).max(1);
849
850 for i in 0..pivot_count {
851 let mut candidate = (i * step) % n;
852 while !used.insert(candidate) {
853 candidate = (candidate + 1) % n;
854 }
855 pivots.push(candidate);
856 }
857
858 let mut bc = vec![0.0_f64; n];
859 let mut scratch = BetweennessScratch::new(n);
860 for pivot in pivots {
861 self.single_source_betweenness(pivot, &mut bc, &mut scratch);
862 }
863
864 let scale = n as f64 / pivot_count.max(1) as f64;
865 for value in &mut bc {
866 *value *= scale;
867 }
868
869 self.map_from_f64_scores(&bc)
870 }
871
872 fn single_source_betweenness(
873 &self,
874 source: usize,
875 bc: &mut [f64],
876 scratch: &mut BetweennessScratch,
877 ) {
878 scratch.reset();
879 scratch.sigma[source] = 1.0;
880 scratch.dist[source] = 0;
881 scratch.queue.push(source);
882
883 let mut queue_head = 0usize;
884 while queue_head < scratch.queue.len() {
885 let v = scratch.queue[queue_head];
886 queue_head += 1;
887 scratch.stack.push(v);
888
889 for &w in &self.successors[v] {
890 if scratch.dist[w] < 0 {
891 scratch.dist[w] = scratch.dist[v] + 1;
892 scratch.queue.push(w);
893 }
894
895 if scratch.dist[w] == scratch.dist[v] + 1 {
896 scratch.sigma[w] += scratch.sigma[v];
897 scratch.pred[w].push(v);
898 }
899 }
900 }
901
902 while let Some(w) = scratch.stack.pop() {
903 let sigma_w = scratch.sigma[w];
904 let delta_w = scratch.delta[w];
905 for &v in &scratch.pred[w] {
906 if sigma_w > 0.0 {
907 scratch.delta[v] += (scratch.sigma[v] / sigma_w) * (1.0 + delta_w);
908 }
909 }
910
911 if w != source {
912 bc[w] += scratch.delta[w];
913 }
914 }
915 }
916
917 fn compute_eigenvector(&self) -> HashMap<String, f64> {
918 let n = self.node_to_id.len();
919 if n == 0 {
920 return HashMap::new();
921 }
922
923 let init = 1.0 / (n as f64).sqrt();
924 let mut current = vec![init; n];
925 let mut next = vec![0.0_f64; n];
926
927 for _ in 0..80 {
928 next.fill(0.0);
929
930 for (node, target) in next.iter_mut().enumerate() {
931 for &pred in &self.predecessors[node] {
932 *target += current[pred];
933 }
934 }
935
936 let norm = next.iter().map(|value| value * value).sum::<f64>().sqrt();
937 if norm < 1e-12 {
938 break;
939 }
940 for value in &mut next {
941 *value /= norm;
942 }
943
944 let delta = current
945 .iter()
946 .zip(next.iter())
947 .map(|(a, b)| (a - b).abs())
948 .sum::<f64>();
949
950 current.clone_from_slice(&next);
951 if delta < 1e-7 {
952 break;
953 }
954 }
955
956 self.map_from_f64_scores(¤t)
957 }
958
959 fn compute_hits(&self) -> (HashMap<String, f64>, HashMap<String, f64>) {
960 let n = self.node_to_id.len();
961 if n == 0 {
962 return (HashMap::new(), HashMap::new());
963 }
964
965 let mut hubs = vec![1.0 / n as f64; n];
966 let mut authorities = vec![1.0 / n as f64; n];
967
968 for _ in 0..100 {
969 let mut next_auth = vec![0.0_f64; n];
970 let mut next_hubs = vec![0.0_f64; n];
971
972 for (node, target) in next_auth.iter_mut().enumerate() {
973 for &pred in &self.predecessors[node] {
974 *target += hubs[pred];
975 }
976 }
977
978 for (node, target) in next_hubs.iter_mut().enumerate() {
979 for &succ in &self.successors[node] {
980 *target += next_auth[succ];
981 }
982 }
983
984 normalize_l2(&mut next_auth);
985 normalize_l2(&mut next_hubs);
986
987 let auth_delta = authorities
988 .iter()
989 .zip(next_auth.iter())
990 .map(|(a, b)| (a - b).abs())
991 .sum::<f64>();
992 let hubs_delta = hubs
993 .iter()
994 .zip(next_hubs.iter())
995 .map(|(a, b)| (a - b).abs())
996 .sum::<f64>();
997
998 authorities = next_auth;
999 hubs = next_hubs;
1000
1001 if auth_delta + hubs_delta < 1e-7 {
1002 break;
1003 }
1004 }
1005
1006 (
1007 self.map_from_f64_scores(&hubs),
1008 self.map_from_f64_scores(&authorities),
1009 )
1010 }
1011
1012 fn compute_critical_depth(&self) -> HashMap<String, usize> {
1013 let n = self.node_to_id.len();
1014 if n == 0 {
1015 return HashMap::new();
1016 }
1017
1018 let mut heights = vec![0usize; n];
1019 if let Some(order) = self.topological_order() {
1020 for node in order {
1021 let max_pred = self.predecessors[node]
1022 .iter()
1023 .map(|&pred| heights[pred])
1024 .max()
1025 .unwrap_or(0);
1026 heights[node] = max_pred.saturating_add(1);
1027 }
1028 }
1029
1030 self.map_from_usize_scores(&heights)
1031 }
1032
1033 fn compute_slack(&self) -> HashMap<String, f64> {
1034 let n = self.node_to_id.len();
1035 if n == 0 {
1036 return HashMap::new();
1037 }
1038
1039 let Some(order) = self.topological_order() else {
1040 return self.map_from_f64_scores(&vec![0.0; n]);
1041 };
1042
1043 let mut dist_from_start = vec![0usize; n];
1044 for &node in &order {
1045 let max_pred = self.predecessors[node]
1046 .iter()
1047 .map(|&pred| dist_from_start[pred])
1048 .max()
1049 .unwrap_or(0);
1050 dist_from_start[node] = max_pred.saturating_add(1);
1051 }
1052
1053 let mut dist_to_end = vec![0usize; n];
1054 for &node in order.iter().rev() {
1055 let max_succ = self.successors[node]
1056 .iter()
1057 .map(|&succ| dist_to_end[succ])
1058 .max()
1059 .unwrap_or(0);
1060 dist_to_end[node] = max_succ.saturating_add(1);
1061 }
1062
1063 let longest_path = (0..n)
1064 .map(|index| dist_from_start[index] + dist_to_end[index] - 1)
1065 .max()
1066 .unwrap_or(0);
1067
1068 let slack = (0..n)
1069 .map(|index| {
1070 let path_through_node = dist_from_start[index] + dist_to_end[index] - 1;
1071 longest_path.saturating_sub(path_through_node) as f64
1072 })
1073 .collect::<Vec<_>>();
1074
1075 self.map_from_f64_scores(&slack)
1076 }
1077
1078 fn compute_k_core(&self) -> HashMap<String, u32> {
1079 let n = self.node_to_id.len();
1080 if n == 0 {
1081 return HashMap::new();
1082 }
1083
1084 let neighbors = self.undirected_neighbors();
1085 let mut degree = neighbors.iter().map(HashSet::len).collect::<Vec<_>>();
1086 let mut removed = vec![false; n];
1087 let mut core = vec![0u32; n];
1088
1089 let mut heap = BinaryHeap::<Reverse<(usize, usize)>>::new();
1090 for (index, °) in degree.iter().enumerate() {
1091 heap.push(Reverse((deg, index)));
1092 }
1093
1094 let mut current_core = 0usize;
1095
1096 while let Some(Reverse((deg, node))) = heap.pop() {
1097 if removed[node] || deg != degree[node] {
1098 continue;
1099 }
1100
1101 removed[node] = true;
1102 current_core = current_core.max(deg);
1103 core[node] = u32::try_from(current_core).unwrap_or(u32::MAX);
1104
1105 for &neighbor in &neighbors[node] {
1106 if removed[neighbor] {
1107 continue;
1108 }
1109
1110 degree[neighbor] = degree[neighbor].saturating_sub(1);
1111 heap.push(Reverse((degree[neighbor], neighbor)));
1112 }
1113 }
1114
1115 self.map_from_u32_scores(&core)
1116 }
1117
1118 fn compute_articulation_points(&self) -> HashSet<String> {
1119 let n = self.node_to_id.len();
1120 if n <= 2 {
1121 return HashSet::new();
1122 }
1123
1124 let neighbors = self
1125 .undirected_neighbors()
1126 .into_iter()
1127 .map(|set| {
1128 let mut values = set.into_iter().collect::<Vec<_>>();
1129 values.sort_unstable();
1130 values
1131 })
1132 .collect::<Vec<_>>();
1133
1134 let mut disc = vec![0usize; n];
1135 let mut low = vec![0usize; n];
1136 let mut parent = vec![usize::MAX; n];
1137 let mut visited = vec![false; n];
1138 let mut is_ap = vec![false; n];
1139 let mut time = 0usize;
1140
1141 for node in 0..n {
1142 if !visited[node] {
1143 tarjan_articulation_dfs(
1144 node,
1145 &neighbors,
1146 &mut disc,
1147 &mut low,
1148 &mut parent,
1149 &mut visited,
1150 &mut is_ap,
1151 &mut time,
1152 );
1153 }
1154 }
1155
1156 is_ap
1157 .iter()
1158 .enumerate()
1159 .filter_map(|(index, &value)| {
1160 if value {
1161 Some(self.node_to_id[index].clone())
1162 } else {
1163 None
1164 }
1165 })
1166 .collect()
1167 }
1168
1169 fn find_cycles(&self) -> Vec<Vec<String>> {
1170 let mut cycles = Vec::new();
1171
1172 for component in kosaraju_scc(&self.graph) {
1173 if component.len() > 1 {
1174 let mut ids: Vec<String> = component
1177 .iter()
1178 .map(|node| self.node_to_id[node.index()].clone())
1179 .collect();
1180 ids.sort();
1181 cycles.push(ids);
1182 continue;
1183 }
1184
1185 let node = component[0];
1186 let has_self_loop = self
1187 .graph
1188 .edges(node)
1189 .any(|edge| edge.target().index() == node.index());
1190 if has_self_loop {
1191 cycles.push(vec![self.node_to_id[node.index()].clone()]);
1192 }
1193 }
1194
1195 cycles.sort_by(|a, b| a.first().cmp(&b.first()));
1196 cycles
1197 }
1198
1199 fn topological_order(&self) -> Option<Vec<usize>> {
1200 let n = self.node_to_id.len();
1201 if n == 0 {
1202 return Some(Vec::new());
1203 }
1204
1205 let mut in_degree = self.predecessors.iter().map(Vec::len).collect::<Vec<_>>();
1206 let mut heap = BinaryHeap::<Reverse<usize>>::new();
1207
1208 for (node, °ree) in in_degree.iter().enumerate() {
1209 if degree == 0 {
1210 heap.push(Reverse(node));
1211 }
1212 }
1213
1214 let mut order = Vec::with_capacity(n);
1215 while let Some(Reverse(node)) = heap.pop() {
1216 order.push(node);
1217
1218 for &succ in &self.successors[node] {
1219 in_degree[succ] = in_degree[succ].saturating_sub(1);
1220 if in_degree[succ] == 0 {
1221 heap.push(Reverse(succ));
1222 }
1223 }
1224 }
1225
1226 if order.len() == n { Some(order) } else { None }
1227 }
1228
1229 fn undirected_neighbors(&self) -> Vec<HashSet<usize>> {
1230 let n = self.node_to_id.len();
1231 let mut neighbors = vec![HashSet::<usize>::new(); n];
1232
1233 for node in 0..n {
1234 for &succ in &self.successors[node] {
1235 if node == succ {
1236 continue;
1237 }
1238 neighbors[node].insert(succ);
1239 neighbors[succ].insert(node);
1240 }
1241 }
1242
1243 neighbors
1244 }
1245
1246 fn map_from_f64_scores(&self, scores: &[f64]) -> HashMap<String, f64> {
1247 let mut map = HashMap::with_capacity(scores.len());
1248 for (index, value) in scores.iter().enumerate() {
1249 map.insert(self.node_to_id[index].clone(), *value);
1250 }
1251 map
1252 }
1253
1254 fn map_from_usize_scores(&self, scores: &[usize]) -> HashMap<String, usize> {
1255 let mut map = HashMap::with_capacity(scores.len());
1256 for (index, value) in scores.iter().enumerate() {
1257 map.insert(self.node_to_id[index].clone(), *value);
1258 }
1259 map
1260 }
1261
1262 fn map_from_u32_scores(&self, scores: &[u32]) -> HashMap<String, u32> {
1263 let mut map = HashMap::with_capacity(scores.len());
1264 for (index, value) in scores.iter().enumerate() {
1265 map.insert(self.node_to_id[index].clone(), *value);
1266 }
1267 map
1268 }
1269}
1270
1271fn normalize_l2(values: &mut [f64]) {
1272 let norm = values.iter().map(|value| value * value).sum::<f64>().sqrt();
1273 if norm > 0.0 {
1274 for value in values {
1275 *value /= norm;
1276 }
1277 }
1278}
1279
1280#[allow(clippy::too_many_arguments)]
1281fn tarjan_articulation_dfs(
1282 node: usize,
1283 neighbors: &[Vec<usize>],
1284 disc: &mut [usize],
1285 low: &mut [usize],
1286 parent: &mut [usize],
1287 visited: &mut [bool],
1288 is_ap: &mut [bool],
1289 time: &mut usize,
1290) {
1291 visited[node] = true;
1292 *time = time.saturating_add(1);
1293 disc[node] = *time;
1294 low[node] = *time;
1295 let mut children = 0usize;
1296
1297 for &next in &neighbors[node] {
1298 if !visited[next] {
1299 children = children.saturating_add(1);
1300 parent[next] = node;
1301
1302 tarjan_articulation_dfs(next, neighbors, disc, low, parent, visited, is_ap, time);
1303
1304 low[node] = low[node].min(low[next]);
1305
1306 if parent[node] == usize::MAX && children > 1 {
1307 is_ap[node] = true;
1308 }
1309 if parent[node] != usize::MAX && low[next] >= disc[node] {
1310 is_ap[node] = true;
1311 }
1312 } else if next != parent[node] {
1313 low[node] = low[node].min(disc[next]);
1314 }
1315 }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use crate::analysis::triage::{TriageOptions, compute_triage};
1321 use crate::model::{Dependency, Issue};
1322
1323 use super::{AnalysisConfig, IssueGraph};
1324
1325 fn triangle_issues() -> Vec<Issue> {
1326 vec![
1327 Issue {
1328 id: "A".to_string(),
1329 title: "A".to_string(),
1330 status: "open".to_string(),
1331 issue_type: "task".to_string(),
1332 priority: 1,
1333 dependencies: vec![Dependency {
1334 issue_id: "A".to_string(),
1335 depends_on_id: "C".to_string(),
1336 dep_type: "blocks".to_string(),
1337 ..Dependency::default()
1338 }],
1339 ..Issue::default()
1340 },
1341 Issue {
1342 id: "B".to_string(),
1343 title: "B".to_string(),
1344 status: "open".to_string(),
1345 issue_type: "task".to_string(),
1346 priority: 1,
1347 dependencies: vec![Dependency {
1348 issue_id: "B".to_string(),
1349 depends_on_id: "A".to_string(),
1350 dep_type: "blocks".to_string(),
1351 ..Dependency::default()
1352 }],
1353 ..Issue::default()
1354 },
1355 Issue {
1356 id: "C".to_string(),
1357 title: "C".to_string(),
1358 status: "open".to_string(),
1359 issue_type: "task".to_string(),
1360 priority: 1,
1361 dependencies: vec![Dependency {
1362 issue_id: "C".to_string(),
1363 depends_on_id: "B".to_string(),
1364 dep_type: "blocks".to_string(),
1365 ..Dependency::default()
1366 }],
1367 ..Issue::default()
1368 },
1369 ]
1370 }
1371
1372 #[test]
1373 fn critical_depth_matches_dependency_direction() {
1374 let issues = vec![
1375 Issue {
1376 id: "A".to_string(),
1377 title: "A".to_string(),
1378 status: "open".to_string(),
1379 issue_type: "task".to_string(),
1380 priority: 1,
1381 ..Issue::default()
1382 },
1383 Issue {
1384 id: "B".to_string(),
1385 title: "B".to_string(),
1386 status: "blocked".to_string(),
1387 issue_type: "task".to_string(),
1388 priority: 2,
1389 dependencies: vec![Dependency {
1390 issue_id: "B".to_string(),
1391 depends_on_id: "A".to_string(),
1392 dep_type: "blocks".to_string(),
1393 ..Dependency::default()
1394 }],
1395 ..Issue::default()
1396 },
1397 ];
1398
1399 let graph = IssueGraph::build(&issues);
1400 let metrics = graph.compute_metrics();
1401
1402 assert_eq!(metrics.critical_depth.get("A"), Some(&2));
1403 assert_eq!(metrics.critical_depth.get("B"), Some(&1));
1404 assert_eq!(metrics.slack.get("A"), Some(&0.0));
1405 assert_eq!(metrics.slack.get("B"), Some(&0.0));
1406 }
1407
1408 #[test]
1409 fn articulation_detects_cut_vertex() {
1410 let issues = vec![
1411 Issue {
1412 id: "A".to_string(),
1413 title: "A".to_string(),
1414 status: "open".to_string(),
1415 issue_type: "task".to_string(),
1416 ..Issue::default()
1417 },
1418 Issue {
1419 id: "B".to_string(),
1420 title: "B".to_string(),
1421 status: "open".to_string(),
1422 issue_type: "task".to_string(),
1423 dependencies: vec![Dependency {
1424 issue_id: "B".to_string(),
1425 depends_on_id: "A".to_string(),
1426 dep_type: "blocks".to_string(),
1427 ..Dependency::default()
1428 }],
1429 ..Issue::default()
1430 },
1431 Issue {
1432 id: "C".to_string(),
1433 title: "C".to_string(),
1434 status: "open".to_string(),
1435 issue_type: "task".to_string(),
1436 dependencies: vec![Dependency {
1437 issue_id: "C".to_string(),
1438 depends_on_id: "A".to_string(),
1439 dep_type: "blocks".to_string(),
1440 ..Dependency::default()
1441 }],
1442 ..Issue::default()
1443 },
1444 ];
1445
1446 let graph = IssueGraph::build(&issues);
1447 let metrics = graph.compute_metrics();
1448
1449 assert!(metrics.articulation_points.contains("A"));
1450 }
1451
1452 #[test]
1453 fn betweenness_finds_middle_node_in_chain() {
1454 let issues = vec![
1455 Issue {
1456 id: "A".to_string(),
1457 title: "A".to_string(),
1458 status: "open".to_string(),
1459 issue_type: "task".to_string(),
1460 dependencies: vec![Dependency {
1461 issue_id: "A".to_string(),
1462 depends_on_id: "B".to_string(),
1463 dep_type: "blocks".to_string(),
1464 ..Dependency::default()
1465 }],
1466 ..Issue::default()
1467 },
1468 Issue {
1469 id: "B".to_string(),
1470 title: "B".to_string(),
1471 status: "open".to_string(),
1472 issue_type: "task".to_string(),
1473 dependencies: vec![Dependency {
1474 issue_id: "B".to_string(),
1475 depends_on_id: "C".to_string(),
1476 dep_type: "blocks".to_string(),
1477 ..Dependency::default()
1478 }],
1479 ..Issue::default()
1480 },
1481 Issue {
1482 id: "C".to_string(),
1483 title: "C".to_string(),
1484 status: "open".to_string(),
1485 issue_type: "task".to_string(),
1486 ..Issue::default()
1487 },
1488 ];
1489
1490 let graph = IssueGraph::build(&issues);
1491 let metrics = graph.compute_metrics();
1492
1493 let a = metrics.betweenness.get("A").copied().unwrap_or_default();
1494 let b = metrics.betweenness.get("B").copied().unwrap_or_default();
1495 let c = metrics.betweenness.get("C").copied().unwrap_or_default();
1496
1497 assert!(b > a);
1498 assert!(b > c);
1499 }
1500
1501 #[test]
1502 fn connected_open_components_group_blocker_cluster() {
1503 let issues = vec![
1504 Issue {
1505 id: "bd-3q0".to_string(),
1506 title: "Primary blocker".to_string(),
1507 status: "in_progress".to_string(),
1508 issue_type: "feature".to_string(),
1509 priority: 1,
1510 ..Issue::default()
1511 },
1512 Issue {
1513 id: "bd-3q1".to_string(),
1514 title: "Blocked follow-on".to_string(),
1515 status: "blocked".to_string(),
1516 issue_type: "task".to_string(),
1517 priority: 2,
1518 dependencies: vec![Dependency {
1519 issue_id: "bd-3q1".to_string(),
1520 depends_on_id: "bd-3q0".to_string(),
1521 dep_type: "blocks".to_string(),
1522 ..Dependency::default()
1523 }],
1524 ..Issue::default()
1525 },
1526 Issue {
1527 id: "bd-3q2".to_string(),
1528 title: "Independent slice".to_string(),
1529 status: "open".to_string(),
1530 issue_type: "task".to_string(),
1531 priority: 3,
1532 ..Issue::default()
1533 },
1534 ];
1535
1536 let graph = IssueGraph::build(&issues);
1537 let components = graph.connected_open_components();
1538 assert_eq!(
1539 components,
1540 vec![
1541 vec!["bd-3q0".to_string(), "bd-3q1".to_string()],
1542 vec!["bd-3q2".to_string()],
1543 ]
1544 );
1545
1546 let metrics = graph.compute_metrics();
1547 assert_eq!(metrics.blocks_count.get("bd-3q0"), Some(&1));
1548 assert!(metrics.cycles.is_empty());
1549 }
1550
1551 #[test]
1552 fn actionable_excludes_dependent_of_waits_for_blocker() {
1553 let issues = vec![
1559 Issue {
1560 id: "BLOCKER".to_string(),
1561 title: "blocker task".to_string(),
1562 status: "open".to_string(),
1563 issue_type: "task".to_string(),
1564 priority: 2,
1565 ..Issue::default()
1566 },
1567 Issue {
1568 id: "DEP".to_string(),
1569 title: "dependent task".to_string(),
1570 status: "open".to_string(),
1571 issue_type: "task".to_string(),
1572 priority: 2,
1573 dependencies: vec![Dependency {
1574 issue_id: "DEP".to_string(),
1575 depends_on_id: "BLOCKER".to_string(),
1576 dep_type: "waits-for".to_string(),
1577 ..Dependency::default()
1578 }],
1579 ..Issue::default()
1580 },
1581 ];
1582
1583 let graph = IssueGraph::build(&issues);
1584 assert_eq!(graph.actionable_ids(), vec!["BLOCKER".to_string()]);
1585 assert_eq!(
1586 graph.blockers("DEP"),
1587 vec!["BLOCKER".to_string()],
1588 "DEP must list BLOCKER as a blocker"
1589 );
1590 }
1591
1592 #[test]
1593 fn actionable_excludes_dependent_of_conditional_blocks() {
1594 let issues = vec![
1596 Issue {
1597 id: "B".to_string(),
1598 title: "blocker".to_string(),
1599 status: "open".to_string(),
1600 issue_type: "task".to_string(),
1601 priority: 2,
1602 ..Issue::default()
1603 },
1604 Issue {
1605 id: "D".to_string(),
1606 title: "dependent".to_string(),
1607 status: "open".to_string(),
1608 issue_type: "task".to_string(),
1609 priority: 2,
1610 dependencies: vec![Dependency {
1611 issue_id: "D".to_string(),
1612 depends_on_id: "B".to_string(),
1613 dep_type: "conditional-blocks".to_string(),
1614 ..Dependency::default()
1615 }],
1616 ..Issue::default()
1617 },
1618 ];
1619
1620 let graph = IssueGraph::build(&issues);
1621 assert_eq!(graph.actionable_ids(), vec!["B".to_string()]);
1622 }
1623
1624 #[test]
1625 fn actionable_excludes_children_of_blocked_parent_epic() {
1626 let issues = vec![
1630 Issue {
1631 id: "B".to_string(),
1632 title: "Blocker".to_string(),
1633 status: "open".to_string(),
1634 issue_type: "task".to_string(),
1635 priority: 1,
1636 ..Issue::default()
1637 },
1638 Issue {
1639 id: "E".to_string(),
1640 title: "Epic".to_string(),
1641 status: "blocked".to_string(),
1642 issue_type: "epic".to_string(),
1643 priority: 2,
1644 dependencies: vec![Dependency {
1645 issue_id: "E".to_string(),
1646 depends_on_id: "B".to_string(),
1647 dep_type: "blocks".to_string(),
1648 ..Dependency::default()
1649 }],
1650 ..Issue::default()
1651 },
1652 Issue {
1653 id: "C".to_string(),
1654 title: "Child task".to_string(),
1655 status: "open".to_string(),
1656 issue_type: "task".to_string(),
1657 priority: 3,
1658 dependencies: vec![Dependency {
1659 issue_id: "C".to_string(),
1660 depends_on_id: "E".to_string(),
1661 dep_type: "parent-child".to_string(),
1662 ..Dependency::default()
1663 }],
1664 ..Issue::default()
1665 },
1666 ];
1667
1668 let graph = IssueGraph::build(&issues);
1669 let actionable = graph.actionable_ids();
1670
1671 assert_eq!(actionable, vec!["B".to_string()]);
1674 }
1675
1676 #[test]
1677 fn actionable_includes_children_of_unblocked_parent() {
1678 let issues = vec![
1682 Issue {
1683 id: "E".to_string(),
1684 title: "Epic".to_string(),
1685 status: "open".to_string(),
1686 issue_type: "epic".to_string(),
1687 priority: 1,
1688 ..Issue::default()
1689 },
1690 Issue {
1691 id: "C".to_string(),
1692 title: "Child task".to_string(),
1693 status: "open".to_string(),
1694 issue_type: "task".to_string(),
1695 priority: 2,
1696 dependencies: vec![Dependency {
1697 issue_id: "C".to_string(),
1698 depends_on_id: "E".to_string(),
1699 dep_type: "parent-child".to_string(),
1700 ..Dependency::default()
1701 }],
1702 ..Issue::default()
1703 },
1704 ];
1705
1706 let graph = IssueGraph::build(&issues);
1707 let actionable = graph.actionable_ids();
1708
1709 assert_eq!(actionable, vec!["C".to_string(), "E".to_string()]);
1710 }
1711
1712 #[test]
1713 fn actionable_handles_mixed_prefix_datasets() {
1714 let issues = vec![
1717 Issue {
1718 id: "bd-100".to_string(),
1719 title: "Beads style".to_string(),
1720 status: "open".to_string(),
1721 issue_type: "task".to_string(),
1722 priority: 1,
1723 ..Issue::default()
1724 },
1725 Issue {
1726 id: "bv-200".to_string(),
1727 title: "Viewer style".to_string(),
1728 status: "open".to_string(),
1729 issue_type: "task".to_string(),
1730 priority: 2,
1731 dependencies: vec![Dependency {
1732 issue_id: "bv-200".to_string(),
1733 depends_on_id: "bd-100".to_string(),
1734 dep_type: "blocks".to_string(),
1735 ..Dependency::default()
1736 }],
1737 ..Issue::default()
1738 },
1739 Issue {
1740 id: "gh-300".to_string(),
1741 title: "GitHub style".to_string(),
1742 status: "open".to_string(),
1743 issue_type: "task".to_string(),
1744 priority: 3,
1745 ..Issue::default()
1746 },
1747 ];
1748
1749 let graph = IssueGraph::build(&issues);
1750 let actionable = graph.actionable_ids();
1751
1752 assert_eq!(actionable, vec!["bd-100".to_string(), "gh-300".to_string()]);
1755 }
1756
1757 #[test]
1758 fn empty_graph_produces_empty_metrics() {
1759 let graph = IssueGraph::build(&[]);
1760 let metrics = graph.compute_metrics();
1761 assert!(metrics.pagerank.is_empty());
1762 assert!(metrics.betweenness.is_empty());
1763 assert!(metrics.cycles.is_empty());
1764 assert!(metrics.articulation_points.is_empty());
1765 assert_eq!(graph.actionable_ids().len(), 0);
1766 }
1767
1768 #[test]
1769 fn single_node_graph() {
1770 let issues = vec![Issue {
1771 id: "SOLO".to_string(),
1772 title: "Alone".to_string(),
1773 status: "open".to_string(),
1774 issue_type: "task".to_string(),
1775 ..Issue::default()
1776 }];
1777 let graph = IssueGraph::build(&issues);
1778 let metrics = graph.compute_metrics();
1779 assert!(metrics.pagerank.contains_key("SOLO"));
1780 assert!(metrics.cycles.is_empty());
1781 assert_eq!(graph.actionable_ids(), vec!["SOLO".to_string()]);
1782 assert!(graph.open_blockers("SOLO").is_empty());
1783 }
1784
1785 #[test]
1786 fn cycle_detected_in_mutual_dependency() {
1787 let issues = vec![
1788 Issue {
1789 id: "X".to_string(),
1790 title: "X".to_string(),
1791 status: "open".to_string(),
1792 issue_type: "task".to_string(),
1793 dependencies: vec![Dependency {
1794 issue_id: "X".to_string(),
1795 depends_on_id: "Y".to_string(),
1796 dep_type: "blocks".to_string(),
1797 ..Dependency::default()
1798 }],
1799 ..Issue::default()
1800 },
1801 Issue {
1802 id: "Y".to_string(),
1803 title: "Y".to_string(),
1804 status: "open".to_string(),
1805 issue_type: "task".to_string(),
1806 dependencies: vec![Dependency {
1807 issue_id: "Y".to_string(),
1808 depends_on_id: "X".to_string(),
1809 dep_type: "blocks".to_string(),
1810 ..Dependency::default()
1811 }],
1812 ..Issue::default()
1813 },
1814 ];
1815 let graph = IssueGraph::build(&issues);
1816 let metrics = graph.compute_metrics();
1817 assert!(
1818 !metrics.cycles.is_empty(),
1819 "mutual dependency should form a cycle"
1820 );
1821 }
1822
1823 #[test]
1824 fn closed_issues_not_actionable() {
1825 let issues = vec![Issue {
1826 id: "DONE".to_string(),
1827 title: "Done".to_string(),
1828 status: "closed".to_string(),
1829 issue_type: "task".to_string(),
1830 ..Issue::default()
1831 }];
1832 let graph = IssueGraph::build(&issues);
1833 assert!(graph.actionable_ids().is_empty());
1834 }
1835
1836 #[test]
1837 fn pagerank_sums_near_one() {
1838 let issues: Vec<Issue> = (0..5)
1839 .map(|i| Issue {
1840 id: format!("N-{i}"),
1841 title: format!("Node {i}"),
1842 status: "open".to_string(),
1843 issue_type: "task".to_string(),
1844 ..Issue::default()
1845 })
1846 .collect();
1847 let graph = IssueGraph::build(&issues);
1848 let metrics = graph.compute_metrics();
1849 let total: f64 = metrics.pagerank.values().sum();
1850 assert!(
1851 (total - 1.0).abs() < 0.1,
1852 "PageRank should sum near 1.0, got {total}"
1853 );
1854 }
1855
1856 #[test]
1859 fn default_config_computes_all_metrics() {
1860 let issues = vec![
1861 Issue {
1862 id: "A".to_string(),
1863 title: "A".to_string(),
1864 status: "open".to_string(),
1865 issue_type: "task".to_string(),
1866 ..Issue::default()
1867 },
1868 Issue {
1869 id: "B".to_string(),
1870 title: "B".to_string(),
1871 status: "open".to_string(),
1872 issue_type: "task".to_string(),
1873 dependencies: vec![Dependency {
1874 depends_on_id: "A".to_string(),
1875 dep_type: "blocks".to_string(),
1876 ..Dependency::default()
1877 }],
1878 ..Issue::default()
1879 },
1880 ];
1881 let graph = IssueGraph::build(&issues);
1882 let metrics = graph.compute_metrics();
1883 assert!(metrics.skipped_metrics.is_empty());
1884 assert!(!metrics.pagerank.is_empty());
1885 assert!(!metrics.betweenness.is_empty());
1886 }
1887
1888 #[test]
1889 fn triage_config_skips_non_essential_metrics() {
1890 let issues = vec![Issue {
1891 id: "A".to_string(),
1892 title: "A".to_string(),
1893 status: "open".to_string(),
1894 issue_type: "task".to_string(),
1895 ..Issue::default()
1896 }];
1897 let graph = IssueGraph::build(&issues);
1898 let config = AnalysisConfig::triage_only();
1899 let metrics = graph.compute_metrics_with_config(&config);
1900
1901 assert!(!metrics.pagerank.is_empty());
1903 assert!(!metrics.betweenness.is_empty());
1904
1905 let skipped_names: Vec<&str> = metrics.skipped_metrics.iter().map(|s| s.metric).collect();
1907 assert!(skipped_names.contains(&"Eigenvector"));
1908 assert!(skipped_names.contains(&"HITS"));
1909 assert!(skipped_names.contains(&"KCore"));
1910 assert!(skipped_names.contains(&"Articulation"));
1911 assert!(skipped_names.contains(&"Slack"));
1912 assert!(metrics.eigenvector.is_empty());
1913 assert!(metrics.hubs.is_empty());
1914 }
1915
1916 #[test]
1917 fn triage_runtime_config_keeps_articulation_but_skips_insight_only_metrics() {
1918 let issues = vec![Issue {
1919 id: "A".to_string(),
1920 title: "A".to_string(),
1921 status: "open".to_string(),
1922 issue_type: "task".to_string(),
1923 ..Issue::default()
1924 }];
1925 let graph = IssueGraph::build(&issues);
1926 let config = AnalysisConfig::triage_runtime();
1927 let metrics = graph.compute_metrics_with_config(&config);
1928
1929 assert!(!metrics.pagerank.is_empty());
1930 assert!(!metrics.betweenness.is_empty());
1931 assert!(metrics.config.enable_articulation);
1932
1933 let skipped_names: Vec<&str> = metrics.skipped_metrics.iter().map(|s| s.metric).collect();
1934 assert!(skipped_names.contains(&"Eigenvector"));
1935 assert!(skipped_names.contains(&"HITS"));
1936 assert!(skipped_names.contains(&"KCore"));
1937 assert!(skipped_names.contains(&"Slack"));
1938 assert!(!skipped_names.contains(&"Articulation"));
1939 }
1940
1941 #[test]
1942 fn config_disables_individual_metrics() {
1943 let issues = vec![Issue {
1944 id: "A".to_string(),
1945 title: "A".to_string(),
1946 status: "open".to_string(),
1947 issue_type: "task".to_string(),
1948 ..Issue::default()
1949 }];
1950 let graph = IssueGraph::build(&issues);
1951 let mut config = AnalysisConfig::full();
1952 config.enable_pagerank = false;
1953 config.enable_cycles = false;
1954
1955 let metrics = graph.compute_metrics_with_config(&config);
1956 assert!(metrics.pagerank.is_empty());
1957 assert!(metrics.cycles.is_empty());
1958 let skipped_names: Vec<&str> = metrics.skipped_metrics.iter().map(|s| s.metric).collect();
1959 assert!(skipped_names.contains(&"PageRank"));
1960 assert!(skipped_names.contains(&"Cycles"));
1961 assert!(!metrics.betweenness.is_empty());
1963 }
1964
1965 #[test]
1966 fn config_size_threshold_skips_betweenness() {
1967 let issues = vec![Issue {
1968 id: "A".to_string(),
1969 title: "A".to_string(),
1970 status: "open".to_string(),
1971 issue_type: "task".to_string(),
1972 ..Issue::default()
1973 }];
1974 let graph = IssueGraph::build(&issues);
1975
1976 let mut config = AnalysisConfig::full();
1978 config.betweenness_max_nodes = 0;
1979
1980 let metrics = graph.compute_metrics_with_config(&config);
1981 assert!(metrics.betweenness.is_empty());
1982 let bt_skip = metrics
1983 .skipped_metrics
1984 .iter()
1985 .find(|s| s.metric == "Betweenness");
1986 assert!(bt_skip.is_some());
1987 assert!(bt_skip.unwrap().reason.contains("too large"));
1988 }
1989
1990 #[test]
1991 fn config_for_size_adapts_to_graph() {
1992 let small = AnalysisConfig::for_size(100);
1993 assert!(small.enable_betweenness);
1994 assert!(small.enable_eigenvector);
1995 assert!(small.enable_hits);
1996
1997 let large = AnalysisConfig::for_size(50_001);
1998 assert!(!large.enable_betweenness);
1999 assert!(!large.enable_eigenvector);
2000 assert!(!large.enable_hits);
2001 }
2002
2003 #[test]
2004 fn config_serializes_to_json() {
2005 let config = AnalysisConfig::full();
2006 let json = serde_json::to_string(&config).unwrap();
2007 assert!(json.contains("\"ComputePageRank\":true"));
2008 assert!(json.contains("\"BetweennessSampleSize\":10000"));
2009 }
2010
2011 #[test]
2012 fn config_deserializes_from_json() {
2013 let json = r#"{
2014 "ComputePageRank": false,
2015 "ComputeBetweenness": true,
2016 "ComputeEigenvector": true,
2017 "ComputeHITS": true,
2018 "ComputeCycles": true,
2019 "ComputeCriticalPath": true,
2020 "ComputeKCore": true,
2021 "ComputeArticulation": true,
2022 "ComputeSlack": true,
2023 "BetweennessSampleSize": 5000,
2024 "eigenvector_max_nodes": 5000,
2025 "BetweennessIsApproximate": false,
2026 "BetweennessMode": "exact",
2027 "BetweennessSkipReason": "",
2028 "BetweennessTimeout": 2000000000,
2029 "PageRankSkipReason": "",
2030 "PageRankTimeout": 2000000000,
2031 "HITSSkipReason": "",
2032 "HITSTimeout": 2000000000,
2033 "CyclesSkipReason": "",
2034 "CyclesTimeout": 2000000000,
2035 "MaxCyclesToStore": 50
2036 }"#;
2037 let config: AnalysisConfig = serde_json::from_str(json).unwrap();
2038 assert!(!config.enable_pagerank);
2039 assert_eq!(config.betweenness_max_nodes, 5000);
2040 assert_eq!(config.max_cycles_to_store, 50);
2041 }
2042
2043 #[test]
2044 fn metrics_config_field_matches_input() {
2045 let issues = vec![Issue {
2046 id: "A".to_string(),
2047 title: "A".to_string(),
2048 status: "open".to_string(),
2049 issue_type: "task".to_string(),
2050 ..Issue::default()
2051 }];
2052 let graph = IssueGraph::build(&issues);
2053 let config = AnalysisConfig::triage_only();
2054 let metrics = graph.compute_metrics_with_config(&config);
2055 assert!(!metrics.config.enable_eigenvector);
2056 assert!(metrics.config.enable_pagerank);
2057 }
2058
2059 #[test]
2060 fn triage_runtime_metrics_preserve_triage_outputs() {
2061 let issues = vec![
2062 Issue {
2063 id: "A".to_string(),
2064 title: "Root blocker".to_string(),
2065 status: "open".to_string(),
2066 issue_type: "feature".to_string(),
2067 priority: 1,
2068 labels: vec!["core".to_string(), "backend".to_string()],
2069 ..Issue::default()
2070 },
2071 Issue {
2072 id: "B".to_string(),
2073 title: "Depends on A".to_string(),
2074 status: "open".to_string(),
2075 issue_type: "task".to_string(),
2076 priority: 2,
2077 labels: vec!["backend".to_string()],
2078 dependencies: vec![Dependency {
2079 issue_id: "B".to_string(),
2080 depends_on_id: "A".to_string(),
2081 dep_type: "blocks".to_string(),
2082 ..Dependency::default()
2083 }],
2084 ..Issue::default()
2085 },
2086 Issue {
2087 id: "C".to_string(),
2088 title: "Also depends on A".to_string(),
2089 status: "open".to_string(),
2090 issue_type: "task".to_string(),
2091 priority: 3,
2092 labels: vec!["frontend".to_string()],
2093 dependencies: vec![Dependency {
2094 issue_id: "C".to_string(),
2095 depends_on_id: "A".to_string(),
2096 dep_type: "blocks".to_string(),
2097 ..Dependency::default()
2098 }],
2099 ..Issue::default()
2100 },
2101 Issue {
2102 id: "D".to_string(),
2103 title: "Independent quick win".to_string(),
2104 status: "open".to_string(),
2105 issue_type: "task".to_string(),
2106 priority: 1,
2107 estimated_minutes: Some(30),
2108 labels: vec!["ops".to_string()],
2109 ..Issue::default()
2110 },
2111 ];
2112
2113 let graph = IssueGraph::build(&issues);
2114 let full_metrics = graph.compute_metrics();
2115 let triage_metrics = graph.compute_metrics_with_config(&AnalysisConfig::triage_runtime());
2116 let options = TriageOptions {
2117 group_by_track: true,
2118 group_by_label: true,
2119 max_recommendations: 20,
2120 ..TriageOptions::default()
2121 };
2122
2123 let full = compute_triage(&issues, &graph, &full_metrics, &options);
2124 let lean = compute_triage(&issues, &graph, &triage_metrics, &options);
2125
2126 let mut full_val = serde_json::to_value(&full.result).unwrap();
2127 let mut lean_val = serde_json::to_value(&lean.result).unwrap();
2128 full_val.as_object_mut().unwrap()["meta"]
2130 .as_object_mut()
2131 .unwrap()
2132 .remove("generated_at");
2133 lean_val.as_object_mut().unwrap()["meta"]
2134 .as_object_mut()
2135 .unwrap()
2136 .remove("generated_at");
2137 assert_eq!(full_val, lean_val);
2138 assert_eq!(full.score_by_id, lean.score_by_id);
2139 }
2140
2141 #[test]
2144 fn fast_phase_skips_expensive_metrics() {
2145 let issues = triangle_issues();
2146 let graph = IssueGraph::build(&issues);
2147 let fast = graph.compute_metrics_with_config(&AnalysisConfig::fast_phase());
2148
2149 assert!(!fast.pagerank.is_empty(), "PageRank should be computed");
2151 assert!(
2152 !fast.blocks_count.is_empty(),
2153 "blocks_count should be computed"
2154 );
2155 assert!(!fast.cycles.is_empty() || fast.cycles.is_empty()); assert!(
2159 fast.betweenness.is_empty(),
2160 "betweenness should be deferred"
2161 );
2162 assert!(
2163 fast.eigenvector.is_empty(),
2164 "eigenvector should be deferred"
2165 );
2166 assert!(fast.hubs.is_empty(), "HITS hubs should be deferred");
2167 assert!(
2168 fast.authorities.is_empty(),
2169 "HITS authorities should be deferred"
2170 );
2171
2172 assert!(
2174 fast.has_pending_slow_metrics(),
2175 "should indicate pending slow metrics"
2176 );
2177 }
2178
2179 #[test]
2180 fn slow_phase_computes_only_expensive_metrics() {
2181 let issues = triangle_issues();
2182 let graph = IssueGraph::build(&issues);
2183 let slow = graph.compute_metrics_with_config(&AnalysisConfig::slow_phase());
2184
2185 assert!(
2187 !slow.betweenness.is_empty(),
2188 "betweenness should be computed"
2189 );
2190 assert!(
2191 !slow.eigenvector.is_empty(),
2192 "eigenvector should be computed"
2193 );
2194 assert!(!slow.hubs.is_empty(), "HITS hubs should be computed");
2195
2196 assert!(slow.pagerank.is_empty(), "PageRank should be skipped");
2198 assert!(slow.k_core.is_empty(), "k_core should be skipped");
2199 }
2200
2201 #[test]
2202 fn merge_slow_produces_complete_metrics() {
2203 let issues = triangle_issues();
2204 let graph = IssueGraph::build(&issues);
2205 let mut fast = graph.compute_metrics_with_config(&AnalysisConfig::fast_phase());
2206 let slow = graph.compute_metrics_with_config(&AnalysisConfig::slow_phase());
2207
2208 assert!(fast.has_pending_slow_metrics());
2209 fast.merge_slow(slow);
2210 assert!(
2211 !fast.has_pending_slow_metrics(),
2212 "should have no pending metrics after merge"
2213 );
2214
2215 assert!(!fast.pagerank.is_empty());
2217 assert!(!fast.betweenness.is_empty());
2218 assert!(!fast.eigenvector.is_empty());
2219 assert!(!fast.hubs.is_empty());
2220 assert!(!fast.k_core.is_empty());
2221 }
2222
2223 #[test]
2224 fn merged_metrics_match_full_computation() {
2225 let issues = triangle_issues();
2226 let graph = IssueGraph::build(&issues);
2227
2228 let full = graph.compute_metrics_with_config(&AnalysisConfig::full());
2230
2231 let mut fast = graph.compute_metrics_with_config(&AnalysisConfig::fast_phase());
2233 let slow = graph.compute_metrics_with_config(&AnalysisConfig::slow_phase());
2234 fast.merge_slow(slow);
2235
2236 assert_eq!(fast.pagerank, full.pagerank);
2238 assert_eq!(fast.betweenness, full.betweenness);
2239 assert_eq!(fast.eigenvector, full.eigenvector);
2240 assert_eq!(fast.hubs, full.hubs);
2241 assert_eq!(fast.authorities, full.authorities);
2242 assert_eq!(fast.k_core, full.k_core);
2243 assert_eq!(fast.articulation_points, full.articulation_points);
2244 assert_eq!(fast.blocks_count, full.blocks_count);
2245 }
2246
2247 #[test]
2248 fn background_threshold_is_reasonable() {
2249 assert!(
2250 AnalysisConfig::BACKGROUND_THRESHOLD >= 100,
2251 "threshold should be >= 100"
2252 );
2253 assert!(
2254 AnalysisConfig::BACKGROUND_THRESHOLD <= 1000,
2255 "threshold should be <= 1000"
2256 );
2257 }
2258
2259 #[test]
2262 fn full_config_enables_all_metrics() {
2263 let c = AnalysisConfig::full();
2264 assert!(c.enable_pagerank);
2265 assert!(c.enable_betweenness);
2266 assert!(c.enable_eigenvector);
2267 assert!(c.enable_hits);
2268 assert!(c.enable_cycles);
2269 assert!(c.enable_critical_path);
2270 assert!(c.enable_k_core);
2271 assert!(c.enable_articulation);
2272 assert!(c.enable_slack);
2273 }
2274
2275 #[test]
2276 fn triage_only_disables_non_triage_metrics() {
2277 let c = AnalysisConfig::triage_only();
2278 assert!(c.enable_pagerank, "triage needs PageRank");
2279 assert!(c.enable_betweenness, "triage needs betweenness");
2280 assert!(c.enable_cycles, "triage needs cycles");
2281 assert!(!c.enable_eigenvector, "triage skips eigenvector");
2282 assert!(!c.enable_hits, "triage skips HITS");
2283 assert!(!c.enable_k_core, "triage skips k-core");
2284 assert!(!c.enable_slack, "triage skips slack");
2285 }
2286
2287 #[test]
2288 fn fast_phase_keeps_all_cheap_metrics() {
2289 let c = AnalysisConfig::fast_phase();
2290 assert!(c.enable_pagerank, "fast keeps PageRank");
2291 assert!(c.enable_cycles, "fast keeps cycles");
2292 assert!(c.enable_critical_path, "fast keeps critical_path");
2293 assert!(c.enable_k_core, "fast keeps k-core");
2294 assert!(c.enable_articulation, "fast keeps articulation");
2295 assert!(c.enable_slack, "fast keeps slack");
2296 assert!(!c.enable_betweenness, "fast defers betweenness");
2298 assert!(!c.enable_eigenvector, "fast defers eigenvector");
2299 assert!(!c.enable_hits, "fast defers HITS");
2300 }
2301
2302 #[test]
2303 fn slow_phase_only_has_expensive_metrics() {
2304 let c = AnalysisConfig::slow_phase();
2305 assert!(c.enable_betweenness, "slow has betweenness");
2306 assert!(c.enable_eigenvector, "slow has eigenvector");
2307 assert!(c.enable_hits, "slow has HITS");
2308 assert!(!c.enable_pagerank, "slow skips PageRank");
2310 assert!(!c.enable_cycles, "slow skips cycles");
2311 assert!(!c.enable_critical_path, "slow skips critical_path");
2312 assert!(!c.enable_k_core, "slow skips k-core");
2313 }
2314
2315 #[test]
2316 fn for_size_disables_expensive_above_threshold() {
2317 let small = AnalysisConfig::for_size(100);
2318 assert!(
2319 small.enable_betweenness,
2320 "100-node graph should compute betweenness"
2321 );
2322 assert!(
2323 small.enable_eigenvector,
2324 "100-node graph should compute eigenvector"
2325 );
2326 assert!(small.enable_hits, "100-node graph should compute HITS");
2327
2328 let large = AnalysisConfig::for_size(50_001);
2329 assert!(
2330 !large.enable_betweenness,
2331 "50k-node graph should skip betweenness"
2332 );
2333 assert!(
2334 !large.enable_eigenvector,
2335 "50k-node graph should skip eigenvector"
2336 );
2337 assert!(!large.enable_hits, "50k-node graph should skip HITS");
2338 }
2339
2340 #[test]
2341 fn for_size_boundary_at_10k() {
2342 let at_boundary = AnalysisConfig::for_size(10_000);
2343 assert!(
2344 at_boundary.enable_betweenness,
2345 "10k should still compute betweenness"
2346 );
2347
2348 let over_boundary = AnalysisConfig::for_size(10_001);
2349 assert!(
2350 !over_boundary.enable_betweenness,
2351 "10k+1 should skip betweenness"
2352 );
2353 assert!(
2354 !over_boundary.enable_eigenvector,
2355 "10k+1 should skip eigenvector"
2356 );
2357 assert!(over_boundary.enable_hits, "10k+1 should still compute HITS");
2359 }
2360
2361 #[test]
2362 fn default_config_equals_full() {
2363 let default = AnalysisConfig::default();
2364 let full = AnalysisConfig::full();
2365 assert_eq!(default.enable_pagerank, full.enable_pagerank);
2366 assert_eq!(default.enable_betweenness, full.enable_betweenness);
2367 assert_eq!(default.enable_eigenvector, full.enable_eigenvector);
2368 assert_eq!(default.enable_hits, full.enable_hits);
2369 assert_eq!(default.betweenness_max_nodes, full.betweenness_max_nodes);
2370 }
2371
2372 #[test]
2373 fn disabled_metric_skipped_and_recorded() {
2374 let issues = triangle_issues();
2375 let graph = IssueGraph::build(&issues);
2376 let mut config = AnalysisConfig::full();
2377 config.enable_betweenness = false;
2378 config.enable_eigenvector = false;
2379
2380 let metrics = graph.compute_metrics_with_config(&config);
2381 assert!(metrics.betweenness.is_empty());
2382 assert!(metrics.eigenvector.is_empty());
2383 assert!(!metrics.pagerank.is_empty()); let skipped_names: Vec<&str> = metrics.skipped_metrics.iter().map(|s| s.metric).collect();
2386 assert!(skipped_names.contains(&"Betweenness"));
2387 assert!(skipped_names.contains(&"Eigenvector"));
2388 }
2389
2390 #[test]
2391 fn betweenness_skipped_when_graph_exceeds_max_nodes() {
2392 let issues = triangle_issues();
2393 let graph = IssueGraph::build(&issues);
2394 let mut config = AnalysisConfig::full();
2395 config.betweenness_max_nodes = 2; let metrics = graph.compute_metrics_with_config(&config);
2398 assert!(
2399 metrics.betweenness.is_empty(),
2400 "betweenness should be skipped for graph exceeding max_nodes"
2401 );
2402 assert!(
2403 metrics
2404 .skipped_metrics
2405 .iter()
2406 .any(|s| s.metric == "Betweenness" && s.reason.contains("too large")),
2407 "should record 'too large' reason"
2408 );
2409 }
2410
2411 #[test]
2412 fn empty_graph_metrics_all_empty() {
2413 let graph = IssueGraph::build(&[]);
2414 let metrics = graph.compute_metrics();
2415 assert!(metrics.pagerank.is_empty());
2416 assert!(metrics.betweenness.is_empty());
2417 assert!(metrics.eigenvector.is_empty());
2418 assert!(metrics.blocks_count.is_empty());
2419 assert!(metrics.cycles.is_empty());
2420 assert!(metrics.skipped_metrics.is_empty());
2421 }
2422
2423 #[test]
2424 fn merge_slow_is_idempotent() {
2425 let issues = triangle_issues();
2426 let graph = IssueGraph::build(&issues);
2427 let mut fast = graph.compute_metrics_with_config(&AnalysisConfig::fast_phase());
2428 let slow = graph.compute_metrics_with_config(&AnalysisConfig::slow_phase());
2429
2430 fast.merge_slow(slow.clone());
2431 let betweenness_after_first = fast.betweenness.clone();
2432
2433 fast.merge_slow(slow);
2435 assert_eq!(
2436 fast.betweenness, betweenness_after_first,
2437 "merge should be idempotent"
2438 );
2439 }
2440
2441 #[test]
2442 fn has_pending_slow_metrics_false_after_full_computation() {
2443 let issues = triangle_issues();
2444 let graph = IssueGraph::build(&issues);
2445 let full = graph.compute_metrics_with_config(&AnalysisConfig::full());
2446 assert!(
2447 !full.has_pending_slow_metrics(),
2448 "full computation should have no pending slow metrics"
2449 );
2450 }
2451}