Skip to main content

bvr/analysis/
graph.rs

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// ---------------------------------------------------------------------------
12// AnalysisConfig – per-metric enable/disable toggles and size thresholds
13// ---------------------------------------------------------------------------
14
15/// Configuration controlling which graph metrics are computed and their resource bounds.
16#[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    /// Skip betweenness for graphs exceeding this node count (expensive: O(V*E)).
38    #[serde(rename = "BetweennessSampleSize")]
39    pub betweenness_max_nodes: usize,
40    /// Skip eigenvector for graphs exceeding this node count.
41    pub eigenvector_max_nodes: usize,
42
43    // Go-compatible betweenness metadata fields
44    #[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    /// All metrics enabled, generous size limits (matches current behavior).
76    #[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    /// Adaptive config based on graph size.
105    #[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    /// Minimal config for triage scoring (only PageRank + betweenness + basic).
134    #[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    /// Runtime config for triage-oriented commands.
163    ///
164    /// Keeps exactly the metrics consumed by triage/plan/priority flows while
165    /// skipping metrics only used by richer insight surfaces.
166    #[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    /// Fast phase: only O(V+E) metrics — returned immediately.
195    ///
196    /// Defers betweenness (O(V*E)), eigenvector (O(V*E) iterative), and
197    /// HITS (O(V*E) iterative) to a background thread.
198    #[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    /// Slow phase: only the expensive O(V*E) metrics.
227    #[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    /// Default node-count threshold above which background computation is used.
256    pub const BACKGROUND_THRESHOLD: usize = 200;
257
258    /// Read the background threshold from `BVR_BACKGROUND_THRESHOLD` env var,
259    /// falling back to [`Self::BACKGROUND_THRESHOLD`] if unset or invalid.
260    #[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/// Record of a metric that was skipped during analysis.
270#[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>>,   // issue -> blockers
285    predecessors: Vec<Vec<usize>>, // issue <- dependents
286    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    /// Merge slow-phase metrics into this fast-phase result.
309    ///
310    /// Fills in betweenness, eigenvector, and HITS from `slow`, removes their
311    /// entries from `skipped_metrics`, and upgrades config to full.
312    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        // Append any skipped_metrics from the slow phase (e.g., if graph was too large)
327        self.skipped_metrics.extend(slow.skipped_metrics);
328        self.config = AnalysisConfig::full();
329    }
330
331    /// Returns true if slow-phase metrics have not yet been computed.
332    #[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        // node_to_id is already in insertion order (sorted by caller);
471        // clone and sort for safety.
472        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        // Phase 1: Compute directly blocked issues (open blocking dependencies).
514        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        // Phase 2: Build parent->children index from parent-child dependencies.
526        // A child has a dep with dep_type="parent-child" and depends_on_id pointing
527        // to the parent. We invert: parent -> [children].
528        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        // Phase 3: Propagate blocked status through parent-child relationships.
544        // If a parent is blocked, its children are also blocked (transitively).
545        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        // Phase 4: Collect actionable issues (open, not blocked).
571        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    /// Compute all metrics using the default (full) config.
621    #[must_use]
622    pub fn compute_metrics(&self) -> GraphMetrics {
623        self.compute_metrics_with_config(&AnalysisConfig::default())
624    }
625
626    /// Compute metrics respecting the provided configuration.
627    #[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        // blocks_count and blocked_by_count are always computed (cheap: O(V)).
689        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(&current)
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, &deg) 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                // Report all SCC members (matches Go behavior which reports
1175                // full strongly-connected components, not minimal cycle paths)
1176                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, &degree) 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        // Regression for bvr #14: when a `waits-for` edge points at an open
1554        // blocker, the dependent issue must NOT be actionable. Previously
1555        // `is_blocking()` only matched `""` | `"blocks"`, so `waits-for`
1556        // silently dropped out of the blocker graph and the dependent was
1557        // classified as actionable / a valid top-pick.
1558        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        // Regression for bvr #14: `conditional-blocks` must also gate readiness.
1595        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        // Parent epic E is blocked by blocker B.
1627        // Child task C has a parent-child dep on E.
1628        // C should NOT be actionable because its parent E is blocked.
1629        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        // Only B should be actionable (it has no blockers).
1672        // E is blocked by B, and C is blocked transitively via parent E.
1673        assert_eq!(actionable, vec!["B".to_string()]);
1674    }
1675
1676    #[test]
1677    fn actionable_includes_children_of_unblocked_parent() {
1678        // Parent epic E has no blockers.
1679        // Child task C has a parent-child dep on E.
1680        // Both should be actionable.
1681        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        // Dataset with mixed prefixes (bd- and bv- style IDs).
1715        // This tests graceful handling of mixed-prefix datasets.
1716        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        // bd-100 is actionable (no blockers), gh-300 is actionable
1753        // bv-200 is blocked by bd-100
1754        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    // -- AnalysisConfig tests --
1857
1858    #[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        // PageRank and betweenness should be computed.
1902        assert!(!metrics.pagerank.is_empty());
1903        assert!(!metrics.betweenness.is_empty());
1904
1905        // Eigenvector, HITS, KCore, Articulation, Slack should be skipped.
1906        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        // Other metrics still computed.
1962        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        // Set threshold to 0 so even 1 node exceeds it.
1977        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        // Strip non-deterministic generated_at timestamps before comparison.
2129        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    // -- Two-phase (fast/slow) metric tests ---------------------------------
2142
2143    #[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        // Fast metrics should be populated
2150        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()); // cycles computed regardless
2156
2157        // Slow metrics should be empty
2158        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        // Skipped metrics should record why
2173        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        // Slow metrics should be populated
2186        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        // Fast-only metrics should be empty
2197        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        // All metrics should now be populated
2216        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        // Full computation
2229        let full = graph.compute_metrics_with_config(&AnalysisConfig::full());
2230
2231        // Two-phase computation
2232        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        // Should produce identical results
2237        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    // -- AnalysisConfig preset validation tests ----------------------------
2260
2261    #[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        // Expensive metrics deferred
2297        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        // Fast metrics skipped
2309        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        // HITS threshold is 50k
2358        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()); // PageRank still computed
2384
2385        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; // Below triangle graph's 3 nodes
2396
2397        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        // Second merge should produce same result
2434        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}