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 #[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 #[must_use]
210 pub fn is_large_graph(&self) -> bool {
211 self.graph.node_count() > graph::AnalysisConfig::background_threshold()
212 }
213
214 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 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 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 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 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 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 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 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 #[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 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 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 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 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 let insights = analyzer.insights();
942 assert!(
943 !insights.influencers.is_empty(),
944 "influencers (PageRank) should still be available"
945 );
946 assert!(insights.betweenness.is_empty());
948 }
949
950 #[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 let fast_triage = fast.triage(options.clone());
959 let fast_scores = fast_triage.score_by_id.clone();
960
961 let slow = fast
963 .graph
964 .compute_metrics_with_config(&AnalysisConfig::slow_phase());
965 fast.apply_slow_metrics(slow);
966
967 let full_triage = fast.triage(options);
969 let full_scores = full_triage.score_by_id;
970
971 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 assert_eq!(
995 full_insights.influencers.len(),
996 two_phase_insights.influencers.len(),
997 "influencer count should match"
998 );
999 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 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}