Skip to main content

bvr/analysis/
mod.rs

1pub mod advanced;
2pub mod alerts;
3pub mod brief;
4pub mod cache;
5pub mod causal;
6pub mod correlation;
7pub mod delivery;
8pub mod diff;
9pub mod drift;
10pub mod economics;
11pub mod file_intel;
12pub mod forecast;
13pub mod git_history;
14pub mod graph;
15pub mod history;
16pub mod label_intel;
17pub mod plan;
18pub mod recipe;
19pub mod search;
20pub mod suggest;
21pub mod triage;
22pub mod whatif;
23
24use std::collections::{HashMap, HashSet};
25
26use serde::Serialize;
27
28use crate::model::Issue;
29
30use self::alerts::{AlertOptions, RobotAlertsOutput};
31use self::diff::SnapshotDiff;
32use self::forecast::ForecastOutput;
33use self::graph::{GraphMetrics, IssueGraph};
34use self::history::IssueHistory;
35use self::plan::ExecutionPlan;
36use self::suggest::{RobotSuggestOutput, SuggestOptions};
37use self::triage::{Recommendation, TriageComputation, TriageOptions, compute_triage};
38
39#[derive(Debug, Clone, Copy, Serialize)]
40pub struct MetricStatusEntry {
41    pub state: &'static str,
42    pub reason: &'static str,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub ms: Option<f64>,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct MetricStatus {
49    #[serde(rename = "PageRank")]
50    pub page_rank: MetricStatusEntry,
51    #[serde(rename = "Betweenness")]
52    pub betweenness: MetricStatusEntry,
53    #[serde(rename = "Eigenvector")]
54    pub eigenvector: MetricStatusEntry,
55    #[serde(rename = "HITS")]
56    pub hits: MetricStatusEntry,
57    #[serde(rename = "Critical")]
58    pub critical: MetricStatusEntry,
59    #[serde(rename = "Cycles")]
60    pub cycles: MetricStatusEntry,
61    #[serde(rename = "KCore")]
62    pub k_core: MetricStatusEntry,
63    #[serde(rename = "Articulation")]
64    pub articulation: MetricStatusEntry,
65    #[serde(rename = "Slack")]
66    pub slack: MetricStatusEntry,
67}
68
69impl MetricStatus {
70    pub const fn computed() -> Self {
71        let entry = MetricStatusEntry {
72            state: "computed",
73            reason: "",
74            ms: None,
75        };
76        Self {
77            page_rank: entry,
78            betweenness: entry,
79            eigenvector: entry,
80            hits: entry,
81            critical: entry,
82            cycles: entry,
83            k_core: entry,
84            articulation: entry,
85            slack: entry,
86        }
87    }
88}
89
90impl Default for MetricStatus {
91    fn default() -> Self {
92        Self::computed()
93    }
94}
95
96#[derive(Debug, Clone, Serialize)]
97pub struct InsightItem {
98    pub id: String,
99    pub title: String,
100    pub score: f64,
101    pub blocks_count: usize,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct MetricItem {
106    pub id: String,
107    pub value: f64,
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct CoreItem {
112    pub id: String,
113    pub value: u32,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct Insights {
118    #[serde(rename = "status")]
119    pub status: MetricStatus,
120    #[serde(rename = "Bottlenecks")]
121    pub bottlenecks: Vec<InsightItem>,
122    #[serde(rename = "CriticalPath")]
123    pub critical_path: Vec<String>,
124    #[serde(rename = "Cycles")]
125    pub cycles: Vec<Vec<String>>,
126    #[serde(rename = "Slack")]
127    pub slack: Vec<String>,
128    #[serde(rename = "Influencers")]
129    pub influencers: Vec<MetricItem>,
130    #[serde(rename = "Betweenness")]
131    pub betweenness: Vec<MetricItem>,
132    #[serde(rename = "Hubs")]
133    pub hubs: Vec<MetricItem>,
134    #[serde(rename = "Authorities")]
135    pub authorities: Vec<MetricItem>,
136    #[serde(rename = "Eigenvector")]
137    pub eigenvector: Vec<MetricItem>,
138    #[serde(rename = "Cores")]
139    pub cores: Vec<CoreItem>,
140    #[serde(rename = "Articulation")]
141    pub articulation_points: Vec<String>,
142    #[serde(rename = "Keystones")]
143    pub keystones: Vec<String>,
144    #[serde(rename = "Orphans")]
145    pub orphans: Vec<String>,
146    #[serde(rename = "ClusterDensity")]
147    pub cluster_density: f64,
148    #[serde(rename = "Velocity")]
149    pub velocity: InsightsVelocity,
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub struct InsightsVelocity {
154    pub closed_last_7_days: usize,
155    pub closed_last_30_days: usize,
156    pub avg_days_to_close: i64,
157    pub weekly: Vec<usize>,
158}
159
160#[derive(Debug, Clone)]
161pub struct Analyzer {
162    pub issues: Vec<Issue>,
163    pub graph: IssueGraph,
164    pub metrics: GraphMetrics,
165}
166
167impl Analyzer {
168    #[must_use]
169    pub fn new(mut issues: Vec<Issue>) -> Self {
170        issues.sort_by(|left, right| left.id.cmp(&right.id));
171        let graph = IssueGraph::build(&issues);
172        let metrics = graph.compute_metrics();
173        Self {
174            issues,
175            graph,
176            metrics,
177        }
178    }
179
180    #[must_use]
181    pub fn new_with_config(mut issues: Vec<Issue>, config: &graph::AnalysisConfig) -> Self {
182        issues.sort_by(|left, right| left.id.cmp(&right.id));
183        let graph = IssueGraph::build(&issues);
184        let metrics = graph.compute_metrics_with_config(config);
185        Self {
186            issues,
187            graph,
188            metrics,
189        }
190    }
191
192    /// Create an analyzer with only fast O(V+E) metrics computed.
193    ///
194    /// Betweenness, eigenvector, and HITS are deferred. Call
195    /// [`spawn_slow_computation`] to compute them in a background thread.
196    #[must_use]
197    pub fn new_fast(mut issues: Vec<Issue>) -> Self {
198        issues.sort_by(|left, right| left.id.cmp(&right.id));
199        let graph = IssueGraph::build(&issues);
200        let metrics = graph.compute_metrics_with_config(&graph::AnalysisConfig::fast_phase());
201        Self {
202            issues,
203            graph,
204            metrics,
205        }
206    }
207
208    /// Returns true if this graph exceeds the background computation threshold.
209    #[must_use]
210    pub fn is_large_graph(&self) -> bool {
211        self.graph.node_count() > graph::AnalysisConfig::background_threshold()
212    }
213
214    /// Spawn a background thread to compute expensive metrics.
215    ///
216    /// Returns a receiver that will yield the slow-phase `GraphMetrics` when done.
217    /// The caller should poll via `try_recv()` and call `apply_slow_metrics()`.
218    pub fn spawn_slow_computation(&self) -> std::sync::mpsc::Receiver<graph::GraphMetrics> {
219        let graph_clone = self.graph.clone();
220        let (tx, rx) = std::sync::mpsc::channel();
221        std::thread::spawn(move || {
222            let slow =
223                graph_clone.compute_metrics_with_config(&graph::AnalysisConfig::slow_phase());
224            let _ = tx.send(slow);
225        });
226        rx
227    }
228
229    /// Merge slow-phase metrics into this analyzer's metrics.
230    pub fn apply_slow_metrics(&mut self, slow: graph::GraphMetrics) {
231        self.metrics.merge_slow(slow);
232    }
233
234    #[must_use]
235    pub fn triage(&self, options: TriageOptions) -> TriageComputation {
236        compute_triage(&self.issues, &self.graph, &self.metrics, &options)
237    }
238
239    #[must_use]
240    pub fn plan(&self, score_by_id: &HashMap<String, f64>) -> ExecutionPlan {
241        plan::compute_execution_plan(&self.graph, score_by_id)
242    }
243
244    #[must_use]
245    pub fn what_if(&self, issue_id: &str) -> Option<whatif::WhatIfDelta> {
246        whatif::compute_what_if(&self.issues, &self.graph, &self.metrics, issue_id)
247    }
248
249    #[must_use]
250    pub fn top_what_ifs(&self, top_n: usize) -> Vec<whatif::WhatIfDelta> {
251        whatif::top_what_if_deltas(&self.issues, &self.graph, &self.metrics, top_n)
252    }
253
254    /// Default limit for insight result lists (bottlenecks, influencers, etc.).
255    pub const DEFAULT_INSIGHT_LIMIT: usize = 20;
256
257    pub fn insights(&self) -> Insights {
258        self.insights_with_limit(Self::DEFAULT_INSIGHT_LIMIT)
259    }
260
261    pub fn insights_with_limit(&self, max_items: usize) -> Insights {
262        let mut bottlenecks = self
263            .issues
264            .iter()
265            .filter(|issue| issue.is_open_like())
266            .map(|issue| {
267                let pagerank = self
268                    .metrics
269                    .pagerank
270                    .get(&issue.id)
271                    .copied()
272                    .unwrap_or_default();
273                let betweenness = self
274                    .metrics
275                    .betweenness
276                    .get(&issue.id)
277                    .copied()
278                    .unwrap_or_default();
279                let blocks_count = self
280                    .metrics
281                    .blocks_count
282                    .get(&issue.id)
283                    .copied()
284                    .unwrap_or_default();
285
286                // PageRank + betweenness favors central blockers and bridges.
287                let score = pagerank + (0.1 * betweenness);
288
289                InsightItem {
290                    id: issue.id.clone(),
291                    title: issue.title.clone(),
292                    score,
293                    blocks_count,
294                }
295            })
296            .collect::<Vec<_>>();
297
298        bottlenecks.sort_by(|left, right| {
299            right
300                .blocks_count
301                .cmp(&left.blocks_count)
302                .then_with(|| right.score.total_cmp(&left.score))
303                .then_with(|| left.id.cmp(&right.id))
304        });
305        bottlenecks.truncate(max_items);
306
307        let mut critical_path = self
308            .metrics
309            .critical_depth
310            .iter()
311            .filter_map(|(id, depth)| {
312                if *depth == 0 {
313                    return None;
314                }
315                Some((id.clone(), *depth))
316            })
317            .collect::<Vec<_>>();
318        critical_path
319            .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
320        critical_path.truncate(max_items);
321
322        let mut zero_slack = self
323            .metrics
324            .slack
325            .iter()
326            .filter_map(|(id, slack)| {
327                if *slack <= 0.001 {
328                    Some(id.clone())
329                } else {
330                    None
331                }
332            })
333            .collect::<Vec<_>>();
334        zero_slack.sort();
335
336        let mut articulation_points = self
337            .metrics
338            .articulation_points
339            .iter()
340            .cloned()
341            .collect::<Vec<_>>();
342        articulation_points.sort();
343
344        // Keystones: articulation points that also block others.
345        let mut keystones = articulation_points
346            .iter()
347            .filter(|id| {
348                self.metrics
349                    .blocks_count
350                    .get(id.as_str())
351                    .is_some_and(|&count| count > 0)
352            })
353            .cloned()
354            .collect::<Vec<_>>();
355        keystones.sort();
356
357        // Orphans: open issues with no dependencies and no dependents.
358        let mut orphans = self
359            .issues
360            .iter()
361            .filter(|issue| {
362                issue.is_open_like()
363                    && self.graph.blockers(&issue.id).is_empty()
364                    && self.graph.dependents(&issue.id).is_empty()
365            })
366            .map(|issue| issue.id.clone())
367            .collect::<Vec<_>>();
368        orphans.sort();
369
370        // Cluster density: edge_count / (node_count * (node_count - 1))
371        let n = self.issues.len();
372        let edge_count: usize = self
373            .issues
374            .iter()
375            .map(|issue| {
376                issue
377                    .dependencies
378                    .iter()
379                    .filter(|d| d.is_blocking())
380                    .count()
381            })
382            .sum();
383        let cluster_density = if n > 1 {
384            edge_count as f64 / (n * (n - 1)) as f64
385        } else {
386            0.0
387        };
388
389        // Velocity: closure stats from project health data.
390        let now = chrono::Utc::now();
391        let closed_7 = self
392            .issues
393            .iter()
394            .filter(|issue| issue.closed_at.is_some_and(|dt| (now - dt).num_days() <= 7))
395            .count();
396        let closed_30 = self
397            .issues
398            .iter()
399            .filter(|issue| {
400                issue
401                    .closed_at
402                    .is_some_and(|dt| (now - dt).num_days() <= 30)
403            })
404            .count();
405        let close_durations: Vec<i64> = self
406            .issues
407            .iter()
408            .filter_map(|issue| {
409                let created = issue.created_at?;
410                let closed = issue.closed_at?;
411                Some((closed - created).num_days())
412            })
413            .collect();
414        let avg_days = if close_durations.is_empty() {
415            0
416        } else {
417            close_durations.iter().sum::<i64>() / close_durations.len() as i64
418        };
419
420        Insights {
421            status: MetricStatus::computed(),
422            bottlenecks,
423            critical_path: critical_path.into_iter().map(|(id, _)| id).collect(),
424            cycles: self.metrics.cycles.clone(),
425            slack: zero_slack,
426            influencers: top_metric_items(&self.metrics.pagerank, max_items),
427            betweenness: top_metric_items(&self.metrics.betweenness, max_items),
428            hubs: top_metric_items(&self.metrics.hubs, max_items),
429            authorities: top_metric_items(&self.metrics.authorities, max_items),
430            eigenvector: top_metric_items(&self.metrics.eigenvector, max_items),
431            cores: top_core_items(&self.metrics.k_core, max_items),
432            articulation_points,
433            keystones,
434            orphans,
435            cluster_density,
436            velocity: InsightsVelocity {
437                closed_last_7_days: closed_7,
438                closed_last_30_days: closed_30,
439                avg_days_to_close: avg_days,
440                weekly: Vec::new(),
441            },
442        }
443    }
444
445    #[must_use]
446    pub fn advanced_insights(&self) -> advanced::AdvancedInsights {
447        advanced::compute_advanced_insights(&self.graph, &self.metrics)
448    }
449
450    /// Compute ONLY the top-k submodular unlock-maximizing set.
451    ///
452    /// Prefer this over `advanced_insights()` when the caller needs just the
453    /// unlock ranking. `advanced_insights()` additionally computes coverage,
454    /// k-paths, cycle-break, parallel-cut, and parallel-gain — each scales
455    /// with graph size, and on a 5k-issue graph that's seconds of wasted work
456    /// when all you want is top_k_set. See issue #4 (`--robot-overview`) for
457    /// the motivating use case.
458    #[must_use]
459    pub fn top_k_unlock_set(&self, k: usize) -> advanced::TopKSetResult {
460        advanced::compute_top_k_set(&self.graph, &self.metrics, k)
461    }
462
463    #[must_use]
464    pub fn priority(
465        &self,
466        min_confidence: f64,
467        max_results: usize,
468        by_label: Option<&str>,
469        by_assignee: Option<&str>,
470    ) -> Vec<Recommendation> {
471        let triage = self.triage(TriageOptions {
472            group_by_track: false,
473            group_by_label: false,
474            max_recommendations: max_results.max(50),
475            ..TriageOptions::default()
476        });
477
478        // Priority view should consider all open issues, including currently blocked items.
479        // Triage intentionally limits to actionable work; augment with non-actionable open
480        // issues so users can still rank and inspect the full active backlog.
481        let mut results = triage.result.recommendations;
482        let actionable_ids = results
483            .iter()
484            .map(|recommendation| recommendation.id.clone())
485            .collect::<HashSet<_>>();
486
487        let max_pagerank = self
488            .metrics
489            .pagerank
490            .values()
491            .copied()
492            .fold(0.0_f64, f64::max)
493            .max(1e-9);
494        let max_unblocks = self
495            .metrics
496            .blocks_count
497            .values()
498            .copied()
499            .max()
500            .unwrap_or(1)
501            .max(1);
502
503        for issue in self
504            .issues
505            .iter()
506            .filter(|issue| issue.is_open_like() && !actionable_ids.contains(&issue.id))
507        {
508            let pagerank = self
509                .metrics
510                .pagerank
511                .get(&issue.id)
512                .copied()
513                .unwrap_or_default();
514            let pagerank_norm = pagerank / max_pagerank;
515
516            let unblocks = self
517                .metrics
518                .blocks_count
519                .get(&issue.id)
520                .copied()
521                .unwrap_or_default();
522            let unblocks_norm = unblocks as f64 / max_unblocks as f64;
523
524            let urgency = match issue.normalized_status().as_str() {
525                "in_progress" => 1.0,
526                "open" => 0.8,
527                "review" => 0.7,
528                "blocked" => 0.5,
529                _ => 0.6,
530            };
531
532            let blockers = self.graph.open_blockers(&issue.id);
533            let is_blocked = !blockers.is_empty();
534            let mut score = (0.45 * pagerank_norm
535                + 0.30 * unblocks_norm
536                + 0.20 * issue.priority_normalized()
537                + 0.05 * urgency)
538                .clamp(0.0, 1.0);
539            if is_blocked {
540                score = (score * 0.9).clamp(0.0, 1.0);
541            }
542
543            let mut reasons = Vec::<String>::new();
544            if is_blocked {
545                reasons.push(format!("currently blocked by {} issue(s)", blockers.len()));
546            }
547            if pagerank_norm > 0.6 {
548                reasons.push("high graph centrality".to_string());
549            }
550            if unblocks > 0 {
551                reasons.push(format!("unblocks {unblocks} issues"));
552            }
553            if issue.priority <= 2 {
554                reasons.push("high declared priority".to_string());
555            }
556            if reasons.is_empty() {
557                reasons.push("ready to execute now".to_string());
558            }
559
560            let action = if issue.normalized_status() == "in_progress" {
561                "Continue work on this issue".to_string()
562            } else {
563                "Start work on this issue".to_string()
564            };
565
566            results.push(Recommendation {
567                id: issue.id.clone(),
568                title: issue.title.clone(),
569                issue_type: issue.issue_type.clone(),
570                status: issue.status.clone(),
571                priority: issue.priority,
572                labels: issue.labels.clone(),
573                score,
574                impact_score: score,
575                confidence: (0.5 + 0.5 * score).clamp(0.0, 1.0),
576                action,
577                reasons,
578                unblocks,
579                unblocks_ids: Vec::new(),
580                blocked_by: Vec::new(),
581                assignee: issue.assignee.clone(),
582                claim_command: format!("br update {} --status=in_progress", issue.id),
583                show_command: format!("br show {}", issue.id),
584                breakdown: None,
585            });
586        }
587
588        results.retain(|rec| rec.confidence >= min_confidence);
589        results.retain(|rec| {
590            by_label.is_none_or(|label| {
591                rec.labels
592                    .iter()
593                    .any(|entry| entry.eq_ignore_ascii_case(label))
594            })
595        });
596        results.retain(|rec| by_assignee.is_none_or(|assignee| rec.assignee == assignee));
597
598        results.sort_by(|left, right| {
599            right
600                .score
601                .total_cmp(&left.score)
602                .then_with(|| left.id.cmp(&right.id))
603        });
604
605        if max_results > 0 {
606            results.truncate(max_results);
607        }
608
609        results
610    }
611
612    #[must_use]
613    pub fn diff(&self, before_issues: &[Issue]) -> SnapshotDiff {
614        diff::compare_snapshots(before_issues, &self.issues)
615    }
616
617    #[must_use]
618    pub fn history(&self, only_issue_id: Option<&str>, limit: usize) -> Vec<IssueHistory> {
619        history::build_histories(&self.issues, only_issue_id, limit)
620    }
621
622    #[must_use]
623    pub fn forecast(
624        &self,
625        issue_id_or_all: &str,
626        label_filter: Option<&str>,
627        agents: usize,
628    ) -> ForecastOutput {
629        forecast::estimate_forecast(
630            &self.issues,
631            &self.graph,
632            &self.metrics,
633            issue_id_or_all,
634            label_filter,
635            agents,
636        )
637    }
638
639    #[must_use]
640    pub fn suggest(&self, options: &SuggestOptions) -> RobotSuggestOutput {
641        suggest::generate_robot_suggest_output(&self.issues, &self.metrics, options)
642    }
643
644    #[must_use]
645    pub fn alerts(&self, options: &AlertOptions) -> RobotAlertsOutput {
646        alerts::generate_robot_alerts_output(&self.issues, &self.graph, &self.metrics, options)
647    }
648}
649
650fn top_metric_items(values: &HashMap<String, f64>, limit: usize) -> Vec<MetricItem> {
651    let mut items = values
652        .iter()
653        .map(|(id, value)| MetricItem {
654            id: id.clone(),
655            value: *value,
656        })
657        .collect::<Vec<_>>();
658
659    items.sort_by(|left, right| {
660        right
661            .value
662            .total_cmp(&left.value)
663            .then_with(|| left.id.cmp(&right.id))
664    });
665
666    if limit > 0 {
667        items.truncate(limit);
668    }
669
670    items
671}
672
673fn top_core_items(values: &HashMap<String, u32>, limit: usize) -> Vec<CoreItem> {
674    let mut items = values
675        .iter()
676        .map(|(id, value)| CoreItem {
677            id: id.clone(),
678            value: *value,
679        })
680        .collect::<Vec<_>>();
681
682    items.sort_by(|left, right| {
683        right
684            .value
685            .cmp(&left.value)
686            .then_with(|| left.id.cmp(&right.id))
687    });
688
689    if limit > 0 {
690        items.truncate(limit);
691    }
692
693    items
694}
695
696#[cfg(test)]
697mod tests {
698    use crate::analysis::graph::AnalysisConfig;
699    use crate::analysis::triage::TriageOptions;
700    use crate::model::{Dependency, Issue};
701
702    use super::Analyzer;
703
704    #[test]
705    fn insights_promote_primary_blocker_for_bd_3q0_slice() {
706        let issues = vec![
707            Issue {
708                id: "bd-3q0".to_string(),
709                title: "Primary blocker".to_string(),
710                status: "in_progress".to_string(),
711                issue_type: "feature".to_string(),
712                priority: 1,
713                ..Issue::default()
714            },
715            Issue {
716                id: "bd-3q1".to_string(),
717                title: "Blocked follow-on".to_string(),
718                status: "blocked".to_string(),
719                issue_type: "task".to_string(),
720                priority: 2,
721                dependencies: vec![Dependency {
722                    issue_id: "bd-3q1".to_string(),
723                    depends_on_id: "bd-3q0".to_string(),
724                    dep_type: "blocks".to_string(),
725                    ..Dependency::default()
726                }],
727                ..Issue::default()
728            },
729            Issue {
730                id: "bd-3q2".to_string(),
731                title: "Independent slice".to_string(),
732                status: "open".to_string(),
733                issue_type: "task".to_string(),
734                priority: 3,
735                ..Issue::default()
736            },
737        ];
738
739        let analyzer = Analyzer::new(issues);
740        let insights = analyzer.insights();
741
742        assert_eq!(
743            insights.bottlenecks.first().map(|item| item.id.as_str()),
744            Some("bd-3q0")
745        );
746        assert_eq!(
747            insights.bottlenecks.first().map(|item| item.blocks_count),
748            Some(1)
749        );
750        assert_eq!(
751            insights.critical_path.first().map(String::as_str),
752            Some("bd-3q0")
753        );
754    }
755
756    #[test]
757    fn triage_runtime_config_preserves_plan_and_priority_outputs() {
758        let issues = vec![
759            Issue {
760                id: "A".to_string(),
761                title: "Root blocker".to_string(),
762                status: "open".to_string(),
763                issue_type: "feature".to_string(),
764                priority: 1,
765                labels: vec!["core".to_string(), "backend".to_string()],
766                ..Issue::default()
767            },
768            Issue {
769                id: "B".to_string(),
770                title: "Depends on A".to_string(),
771                status: "open".to_string(),
772                issue_type: "task".to_string(),
773                priority: 2,
774                labels: vec!["backend".to_string()],
775                dependencies: vec![Dependency {
776                    issue_id: "B".to_string(),
777                    depends_on_id: "A".to_string(),
778                    dep_type: "blocks".to_string(),
779                    ..Dependency::default()
780                }],
781                ..Issue::default()
782            },
783            Issue {
784                id: "C".to_string(),
785                title: "Also depends on A".to_string(),
786                status: "open".to_string(),
787                issue_type: "task".to_string(),
788                priority: 3,
789                labels: vec!["frontend".to_string()],
790                dependencies: vec![Dependency {
791                    issue_id: "C".to_string(),
792                    depends_on_id: "A".to_string(),
793                    dep_type: "blocks".to_string(),
794                    ..Dependency::default()
795                }],
796                ..Issue::default()
797            },
798            Issue {
799                id: "D".to_string(),
800                title: "Independent quick win".to_string(),
801                status: "open".to_string(),
802                issue_type: "task".to_string(),
803                priority: 1,
804                estimated_minutes: Some(30),
805                labels: vec!["ops".to_string()],
806                ..Issue::default()
807            },
808        ];
809
810        let full = Analyzer::new(issues.clone());
811        let lean = Analyzer::new_with_config(issues, &AnalysisConfig::triage_runtime());
812        let triage_options = TriageOptions {
813            max_recommendations: 20,
814            ..TriageOptions::default()
815        };
816
817        let full_triage = full.triage(triage_options.clone());
818        let lean_triage = lean.triage(triage_options);
819
820        let full_plan = full.plan(&full_triage.score_by_id);
821        let lean_plan = lean.plan(&lean_triage.score_by_id);
822        assert_eq!(
823            serde_json::to_value(&full_plan).unwrap(),
824            serde_json::to_value(&lean_plan).unwrap()
825        );
826
827        let full_priority = full.priority(0.0, 20, None, None);
828        let lean_priority = lean.priority(0.0, 20, None, None);
829        assert_eq!(
830            serde_json::to_value(&full_priority).unwrap(),
831            serde_json::to_value(&lean_priority).unwrap()
832        );
833    }
834
835    // -- Two-phase (fast/slow) Analyzer tests --------------------------------
836
837    fn sample_issues() -> Vec<Issue> {
838        vec![
839            Issue {
840                id: "A".to_string(),
841                title: "Root".to_string(),
842                status: "open".to_string(),
843                issue_type: "task".to_string(),
844                priority: 1,
845                ..Issue::default()
846            },
847            Issue {
848                id: "B".to_string(),
849                title: "Blocked".to_string(),
850                status: "open".to_string(),
851                issue_type: "task".to_string(),
852                priority: 2,
853                dependencies: vec![Dependency {
854                    issue_id: "B".to_string(),
855                    depends_on_id: "A".to_string(),
856                    dep_type: "blocks".to_string(),
857                    ..Dependency::default()
858                }],
859                ..Issue::default()
860            },
861            Issue {
862                id: "C".to_string(),
863                title: "Closed".to_string(),
864                status: "closed".to_string(),
865                issue_type: "task".to_string(),
866                ..Issue::default()
867            },
868        ]
869    }
870
871    #[test]
872    fn new_fast_defers_slow_metrics() {
873        let analyzer = Analyzer::new_fast(sample_issues());
874        assert!(
875            analyzer.metrics.has_pending_slow_metrics(),
876            "fast analyzer should have pending slow metrics"
877        );
878        // PageRank should still be available
879        assert!(
880            !analyzer.metrics.pagerank.is_empty(),
881            "fast analyzer should have PageRank"
882        );
883    }
884
885    #[test]
886    fn apply_slow_metrics_fills_gaps() {
887        let mut analyzer = Analyzer::new_fast(sample_issues());
888        assert!(analyzer.metrics.betweenness.is_empty());
889
890        let slow = analyzer
891            .graph
892            .compute_metrics_with_config(&AnalysisConfig::slow_phase());
893        analyzer.apply_slow_metrics(slow);
894
895        assert!(
896            !analyzer.metrics.betweenness.is_empty(),
897            "betweenness should be filled after applying slow metrics"
898        );
899        assert!(
900            !analyzer.metrics.has_pending_slow_metrics(),
901            "should have no pending slow metrics"
902        );
903    }
904
905    #[test]
906    fn is_large_graph_below_threshold() {
907        let analyzer = Analyzer::new(sample_issues());
908        assert!(
909            !analyzer.is_large_graph(),
910            "3-node graph should not be large"
911        );
912    }
913
914    #[test]
915    fn spawn_slow_computation_completes() {
916        let analyzer = Analyzer::new_fast(sample_issues());
917        let rx = analyzer.spawn_slow_computation();
918        let slow = rx.recv().expect("should receive slow metrics");
919        assert!(
920            !slow.betweenness.is_empty(),
921            "background thread should compute betweenness"
922        );
923    }
924
925    #[test]
926    fn fast_triage_still_works() {
927        let analyzer = Analyzer::new_fast(sample_issues());
928        let options = TriageOptions::default();
929        // Triage should work with fast-only metrics (betweenness component will be 0)
930        let triage = analyzer.triage(options);
931        assert!(
932            !triage.result.recommendations.is_empty() || analyzer.issues.is_empty(),
933            "triage should return results even with fast-only metrics"
934        );
935    }
936
937    #[test]
938    fn fast_insights_still_works() {
939        let analyzer = Analyzer::new_fast(sample_issues());
940        // Insights should not panic even with missing metrics
941        let insights = analyzer.insights();
942        assert!(
943            !insights.influencers.is_empty(),
944            "influencers (PageRank) should still be available"
945        );
946        // Betweenness-based fields will be empty but shouldn't panic
947        assert!(insights.betweenness.is_empty());
948    }
949
950    // -- Integration: config → analysis → triage chain ---------------------
951
952    #[test]
953    fn triage_scores_improve_after_slow_metrics_applied() {
954        let mut fast = Analyzer::new_fast(sample_issues());
955        let options = TriageOptions::default();
956
957        // Fast-only triage (betweenness component is 0)
958        let fast_triage = fast.triage(options.clone());
959        let fast_scores = fast_triage.score_by_id.clone();
960
961        // Apply slow metrics
962        let slow = fast
963            .graph
964            .compute_metrics_with_config(&AnalysisConfig::slow_phase());
965        fast.apply_slow_metrics(slow);
966
967        // Full triage (betweenness component now available)
968        let full_triage = fast.triage(options);
969        let full_scores = full_triage.score_by_id;
970
971        // Scores should differ (betweenness now contributes)
972        // For the sample graph, A blocks B so should have nonzero betweenness
973        let a_fast = fast_scores.get("A").copied().unwrap_or(0.0);
974        let a_full = full_scores.get("A").copied().unwrap_or(0.0);
975        assert!(
976            (a_fast - a_full).abs() > 0.0 || fast_scores.len() == full_scores.len(),
977            "scores should differ or graph is degenerate: fast={a_fast}, full={a_full}"
978        );
979    }
980
981    #[test]
982    fn fast_then_slow_produces_same_insights_as_full() {
983        let full = Analyzer::new(sample_issues());
984        let full_insights = full.insights();
985
986        let mut two_phase = Analyzer::new_fast(sample_issues());
987        let slow = two_phase
988            .graph
989            .compute_metrics_with_config(&AnalysisConfig::slow_phase());
990        two_phase.apply_slow_metrics(slow);
991        let two_phase_insights = two_phase.insights();
992
993        // Influencers (PageRank-based) should match exactly
994        assert_eq!(
995            full_insights.influencers.len(),
996            two_phase_insights.influencers.len(),
997            "influencer count should match"
998        );
999        // Betweenness should now match
1000        assert_eq!(
1001            full_insights.betweenness.len(),
1002            two_phase_insights.betweenness.len(),
1003            "betweenness item count should match"
1004        );
1005    }
1006
1007    #[test]
1008    fn new_with_config_respects_selective_metrics() {
1009        let config = AnalysisConfig {
1010            enable_pagerank: true,
1011            enable_betweenness: false,
1012            enable_eigenvector: false,
1013            enable_hits: false,
1014            enable_cycles: true,
1015            enable_critical_path: false,
1016            enable_k_core: false,
1017            enable_articulation: false,
1018            enable_slack: false,
1019            betweenness_max_nodes: 10_000,
1020            eigenvector_max_nodes: 10_000,
1021            betweenness_is_approximate: false,
1022            betweenness_mode: "exact",
1023            betweenness_skip_reason: "",
1024            betweenness_timeout_ns: 2_000_000_000,
1025            pagerank_skip_reason: "",
1026            pagerank_timeout_ns: 2_000_000_000,
1027            hits_skip_reason: "",
1028            hits_timeout_ns: 2_000_000_000,
1029            cycles_skip_reason: "",
1030            cycles_timeout_ns: 2_000_000_000,
1031            max_cycles_to_store: 100,
1032        };
1033        let analyzer = Analyzer::new_with_config(sample_issues(), &config);
1034        assert!(
1035            !analyzer.metrics.pagerank.is_empty(),
1036            "PageRank should be computed"
1037        );
1038        assert!(
1039            analyzer.metrics.betweenness.is_empty(),
1040            "betweenness should be skipped"
1041        );
1042        assert!(
1043            analyzer.metrics.eigenvector.is_empty(),
1044            "eigenvector should be skipped"
1045        );
1046        assert!(
1047            analyzer.metrics.k_core.is_empty(),
1048            "k_core should be skipped"
1049        );
1050    }
1051
1052    #[test]
1053    fn empty_issues_all_operations_succeed() {
1054        let analyzer = Analyzer::new(vec![]);
1055        assert!(analyzer.issues.is_empty());
1056        assert_eq!(analyzer.graph.node_count(), 0);
1057
1058        // All operations should succeed on empty graph
1059        let triage = analyzer.triage(TriageOptions::default());
1060        assert!(triage.result.recommendations.is_empty());
1061
1062        let insights = analyzer.insights();
1063        assert!(insights.influencers.is_empty());
1064        assert!(insights.cycles.is_empty());
1065
1066        let plan = analyzer.plan(&std::collections::HashMap::new());
1067        assert!(plan.tracks.is_empty());
1068    }
1069
1070    #[test]
1071    fn empty_issues_fast_phase_no_panic() {
1072        let analyzer = Analyzer::new_fast(vec![]);
1073        assert!(!analyzer.is_large_graph());
1074        let rx = analyzer.spawn_slow_computation();
1075        let slow = rx.recv().expect("should complete even for empty graph");
1076        assert!(slow.betweenness.is_empty());
1077    }
1078}