1use serde::{Deserialize, Serialize};
60use std::collections::{HashMap, HashSet, VecDeque};
61use std::path::PathBuf;
62
63use crate::types::FunctionRef;
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PageRankConfig {
77 pub damping: f64,
80 pub max_iterations: usize,
83 pub epsilon: f64,
86}
87
88impl Default for PageRankConfig {
89 fn default() -> Self {
90 Self {
91 damping: 0.85,
92 max_iterations: 150,
93 epsilon: 1e-5,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct BetweennessConfig {
104 pub sample_size: Option<usize>,
107 pub max_nodes: usize,
110}
111
112impl Default for BetweennessConfig {
113 fn default() -> Self {
114 Self {
115 sample_size: None,
116 max_nodes: 5000,
117 }
118 }
119}
120
121impl BetweennessConfig {
122 pub fn with_sampling(sample_size: usize) -> Self {
124 Self {
125 sample_size: Some(sample_size),
126 max_nodes: 5000,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct PageRankResult {
134 pub scores: HashMap<FunctionRef, f64>,
136 pub iterations_used: usize,
138 pub converged: bool,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum RiskLevel {
156 Critical,
158 High,
160 Medium,
162 Low,
164}
165
166impl RiskLevel {
167 pub fn from_score(score: f64) -> Self {
175 if score >= 0.8 {
176 RiskLevel::Critical
177 } else if score >= 0.6 {
178 RiskLevel::High
179 } else if score >= 0.4 {
180 RiskLevel::Medium
181 } else {
182 RiskLevel::Low
183 }
184 }
185}
186
187impl std::fmt::Display for RiskLevel {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 match self {
190 RiskLevel::Critical => write!(f, "critical"),
191 RiskLevel::High => write!(f, "high"),
192 RiskLevel::Medium => write!(f, "medium"),
193 RiskLevel::Low => write!(f, "low"),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct HubScore {
203 pub function_ref: FunctionRef,
205 pub file: PathBuf,
207 pub name: String,
209 pub in_degree: f64,
211 pub out_degree: f64,
213 pub pagerank: Option<f64>,
216 pub betweenness: Option<f64>,
219 pub callers_count: usize,
221 pub callees_count: usize,
223 pub composite_score: f64,
225 pub risk_level: RiskLevel,
227}
228
229pub const WEIGHT_IN_DEGREE: f64 = 0.25;
231pub const WEIGHT_OUT_DEGREE: f64 = 0.25;
233pub const WEIGHT_BETWEENNESS: f64 = 0.30;
235pub const WEIGHT_PAGERANK: f64 = 0.20;
237
238impl HubScore {
239 pub fn new(
248 function_ref: FunctionRef,
249 in_degree: f64,
250 out_degree: f64,
251 callers_count: usize,
252 callees_count: usize,
253 ) -> Self {
254 let composite_score = (in_degree + out_degree) / 2.0;
256 let risk_level = RiskLevel::from_score(composite_score);
257
258 Self {
259 file: function_ref.file.clone(),
260 name: function_ref.name.clone(),
261 function_ref,
262 in_degree,
263 out_degree,
264 pagerank: None,
265 betweenness: None,
266 callers_count,
267 callees_count,
268 composite_score,
269 risk_level,
270 }
271 }
272
273 pub fn with_all_measures(
281 function_ref: FunctionRef,
282 in_degree: f64,
283 out_degree: f64,
284 pagerank: f64,
285 betweenness: f64,
286 callers_count: usize,
287 callees_count: usize,
288 ) -> Self {
289 let composite_score =
290 compute_composite_score(in_degree, out_degree, Some(pagerank), Some(betweenness));
291 let risk_level = RiskLevel::from_score(composite_score);
292
293 Self {
294 file: function_ref.file.clone(),
295 name: function_ref.name.clone(),
296 function_ref,
297 in_degree,
298 out_degree,
299 pagerank: Some(pagerank),
300 betweenness: Some(betweenness),
301 callers_count,
302 callees_count,
303 composite_score,
304 risk_level,
305 }
306 }
307
308 pub fn with_composite(
312 function_ref: FunctionRef,
313 in_degree: f64,
314 out_degree: f64,
315 callers_count: usize,
316 callees_count: usize,
317 composite_score: f64,
318 ) -> Self {
319 let risk_level = RiskLevel::from_score(composite_score);
320
321 Self {
322 file: function_ref.file.clone(),
323 name: function_ref.name.clone(),
324 function_ref,
325 in_degree,
326 out_degree,
327 pagerank: None,
328 betweenness: None,
329 callers_count,
330 callees_count,
331 composite_score,
332 risk_level,
333 }
334 }
335
336 pub fn with_optional_measures(
338 function_ref: FunctionRef,
339 in_degree: f64,
340 out_degree: f64,
341 pagerank: Option<f64>,
342 betweenness: Option<f64>,
343 callers_count: usize,
344 callees_count: usize,
345 ) -> Self {
346 let composite_score = compute_composite_score(in_degree, out_degree, pagerank, betweenness);
347 let risk_level = RiskLevel::from_score(composite_score);
348
349 Self {
350 file: function_ref.file.clone(),
351 name: function_ref.name.clone(),
352 function_ref,
353 in_degree,
354 out_degree,
355 pagerank,
356 betweenness,
357 callers_count,
358 callees_count,
359 composite_score,
360 risk_level,
361 }
362 }
363}
364
365pub fn compute_composite_score(
374 in_degree: f64,
375 out_degree: f64,
376 pagerank: Option<f64>,
377 betweenness: Option<f64>,
378) -> f64 {
379 let mut total_weight = WEIGHT_IN_DEGREE + WEIGHT_OUT_DEGREE;
380 let mut weighted_sum = WEIGHT_IN_DEGREE * in_degree + WEIGHT_OUT_DEGREE * out_degree;
381
382 if let Some(pr) = pagerank {
383 weighted_sum += WEIGHT_PAGERANK * pr;
384 total_weight += WEIGHT_PAGERANK;
385 }
386
387 if let Some(bc) = betweenness {
388 weighted_sum += WEIGHT_BETWEENNESS * bc;
389 total_weight += WEIGHT_BETWEENNESS;
390 }
391
392 if total_weight > 0.0 {
393 weighted_sum / total_weight
394 } else {
395 0.0
396 }
397}
398
399pub fn compute_in_degree(
427 nodes: &HashSet<FunctionRef>,
428 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
429) -> HashMap<FunctionRef, f64> {
430 let n = nodes.len();
431
432 if n == 0 {
434 return HashMap::new();
435 }
436
437 if n == 1 {
439 return nodes.iter().map(|node| (node.clone(), 0.0)).collect();
440 }
441
442 let max_degree = (n - 1) as f64;
443
444 nodes
445 .iter()
446 .map(|node| {
447 let callers_count = reverse_graph
448 .get(node)
449 .map(|callers| callers.len())
450 .unwrap_or(0);
451
452 let normalized = callers_count as f64 / max_degree;
453 (node.clone(), normalized)
454 })
455 .collect()
456}
457
458pub fn compute_out_degree(
482 nodes: &HashSet<FunctionRef>,
483 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
484) -> HashMap<FunctionRef, f64> {
485 let n = nodes.len();
486
487 if n == 0 {
489 return HashMap::new();
490 }
491
492 if n == 1 {
494 return nodes.iter().map(|node| (node.clone(), 0.0)).collect();
495 }
496
497 let max_degree = (n - 1) as f64;
498
499 nodes
500 .iter()
501 .map(|node| {
502 let callees_count = forward_graph
503 .get(node)
504 .map(|callees| callees.len())
505 .unwrap_or(0);
506
507 let normalized = callees_count as f64 / max_degree;
508 (node.clone(), normalized)
509 })
510 .collect()
511}
512
513pub fn get_caller_counts(
522 nodes: &HashSet<FunctionRef>,
523 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
524) -> HashMap<FunctionRef, usize> {
525 nodes
526 .iter()
527 .map(|node| {
528 let count = reverse_graph
529 .get(node)
530 .map(|callers| callers.len())
531 .unwrap_or(0);
532 (node.clone(), count)
533 })
534 .collect()
535}
536
537pub fn compute_pagerank(
578 nodes: &HashSet<FunctionRef>,
579 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
580 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
581 config: &PageRankConfig,
582) -> PageRankResult {
583 let n = nodes.len();
584
585 if n == 0 {
587 return PageRankResult {
588 scores: HashMap::new(),
589 iterations_used: 0,
590 converged: true,
591 };
592 }
593
594 if n == 1 {
595 return PageRankResult {
596 scores: nodes.iter().map(|node| (node.clone(), 1.0)).collect(),
597 iterations_used: 0,
598 converged: true,
599 };
600 }
601
602 let n_f64 = n as f64;
603 let d = config.damping;
604 let base_score = (1.0 - d) / n_f64;
605
606 let mut scores: HashMap<FunctionRef, f64> = nodes
608 .iter()
609 .map(|node| (node.clone(), 1.0 / n_f64))
610 .collect();
611
612 let out_degrees: HashMap<FunctionRef, usize> = nodes
616 .iter()
617 .map(|node| {
618 let deg = forward_graph.get(node).map_or(0, |v| v.len());
621 (node.clone(), deg)
622 })
623 .collect();
624
625 let dangling_nodes: Vec<FunctionRef> = nodes
628 .iter()
629 .filter(|node| out_degrees.get(*node).copied().unwrap_or(0) == 0)
630 .cloned()
631 .collect();
632
633 let mut iterations_used = 0;
634 let mut converged = false;
635
636 for _ in 0..config.max_iterations {
637 iterations_used += 1;
638
639 let dangling_sum: f64 = dangling_nodes.iter().map(|node| scores[node]).sum();
641 let dangling_contrib = dangling_sum / n_f64;
642
643 let mut new_scores: HashMap<FunctionRef, f64> = HashMap::new();
644 let mut max_delta: f64 = 0.0;
645
646 for node in nodes {
647 let incoming_contrib: f64 = reverse_graph.get(node).map_or(0.0, |callers| {
650 callers
651 .iter()
652 .map(|caller| {
653 let caller_out_deg = out_degrees.get(caller).copied().unwrap_or(0);
654 if caller_out_deg > 0 {
655 scores[caller] / caller_out_deg as f64
656 } else {
657 0.0
658 }
659 })
660 .sum()
661 });
662
663 let new_score = base_score + d * (incoming_contrib + dangling_contrib);
665
666 let delta = (new_score - scores[node]).abs();
667 if delta > max_delta {
668 max_delta = delta;
669 }
670
671 new_scores.insert(node.clone(), new_score);
672 }
673
674 scores = new_scores;
675
676 if max_delta < config.epsilon {
678 converged = true;
679 break;
680 }
681 }
682
683 let max_score = scores.values().copied().fold(0.0_f64, f64::max);
685 if max_score > 0.0 {
686 for score in scores.values_mut() {
687 *score /= max_score;
688 }
689 }
690
691 PageRankResult {
692 scores,
693 iterations_used,
694 converged,
695 }
696}
697
698pub fn compute_betweenness(
737 nodes: &HashSet<FunctionRef>,
738 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
739 config: &BetweennessConfig,
740) -> HashMap<FunctionRef, f64> {
741 let n = nodes.len();
742
743 if n <= 2 {
745 return nodes.iter().map(|node| (node.clone(), 0.0)).collect();
746 }
747
748 if n > config.max_nodes {
750 return nodes.iter().map(|node| (node.clone(), 0.0)).collect();
753 }
754
755 let node_list: Vec<FunctionRef> = nodes.iter().cloned().collect();
757
758 let sources: Vec<&FunctionRef> = match config.sample_size {
760 Some(k) if k < n => {
761 let step = n / k.max(1);
764 (0..k).map(|i| &node_list[(i * step) % n]).collect()
765 }
766 _ => {
767 node_list.iter().collect()
769 }
770 };
771
772 let num_sources = sources.len();
773 let scaling_factor = if num_sources < n {
774 n as f64 / num_sources as f64
775 } else {
776 1.0
777 };
778
779 let mut betweenness: HashMap<FunctionRef, f64> =
780 nodes.iter().map(|node| (node.clone(), 0.0)).collect();
781
782 for source in &sources {
784 let mut dist: HashMap<&FunctionRef, usize> = HashMap::new();
786 let mut sigma: HashMap<&FunctionRef, f64> = HashMap::new();
787 let mut pred: HashMap<&FunctionRef, Vec<&FunctionRef>> = HashMap::new();
788
789 dist.insert(source, 0);
790 sigma.insert(source, 1.0);
791
792 let mut queue: VecDeque<&FunctionRef> = VecDeque::new();
793 queue.push_back(source);
794
795 let mut order: Vec<&FunctionRef> = Vec::new();
796
797 while let Some(current) = queue.pop_front() {
798 order.push(current);
799
800 if let Some(neighbors) = forward_graph.get(current) {
802 for neighbor in neighbors {
803 if !nodes.contains(neighbor) {
804 continue;
805 }
806 if !dist.contains_key(&neighbor) {
808 dist.insert(neighbor, dist[¤t] + 1);
809 queue.push_back(neighbor);
810 }
811
812 if dist.get(&neighbor) == Some(&(dist[¤t] + 1)) {
814 *sigma.entry(neighbor).or_insert(0.0) += sigma[¤t];
815 pred.entry(neighbor).or_default().push(current);
816 }
817 }
818 }
819 }
820
821 let mut delta: HashMap<&FunctionRef, f64> =
823 node_list.iter().map(|node| (node, 0.0)).collect();
824
825 while let Some(w) = order.pop() {
827 if let Some(predecessors) = pred.get(&w) {
828 for v in predecessors {
829 let sigma_v = sigma.get(v).copied().unwrap_or(0.0);
830 let sigma_w = sigma.get(&w).copied().unwrap_or(0.0);
831 if sigma_w > 0.0 {
832 let contribution = (sigma_v / sigma_w) * (1.0 + delta[&w]);
833 *delta.get_mut(v).unwrap() += contribution;
834 }
835 }
836 }
837
838 if w != *source {
840 *betweenness.get_mut(w).unwrap() += delta[&w];
841 }
842 }
843 }
844
845 if scaling_factor > 1.0 {
847 for value in betweenness.values_mut() {
848 *value *= scaling_factor;
849 }
850 }
851
852 let normalizer = ((n - 1) * (n - 2)) as f64;
854 if normalizer > 0.0 {
855 for value in betweenness.values_mut() {
856 *value /= normalizer;
857 }
858 }
859
860 let max_val = betweenness.values().copied().fold(0.0_f64, f64::max);
862 if max_val > 1.0 {
863 for value in betweenness.values_mut() {
864 *value /= max_val;
865 }
866 }
867
868 betweenness
869}
870
871pub fn get_callee_counts(
884 nodes: &HashSet<FunctionRef>,
885 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
886) -> HashMap<FunctionRef, usize> {
887 nodes
888 .iter()
889 .map(|node| {
890 let count = forward_graph
891 .get(node)
892 .map(|callees| callees.len())
893 .unwrap_or(0);
894 (node.clone(), count)
895 })
896 .collect()
897}
898
899pub fn compute_hub_scores(
913 nodes: &HashSet<FunctionRef>,
914 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
915 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
916) -> Vec<HubScore> {
917 let in_degrees = compute_in_degree(nodes, reverse_graph);
918 let out_degrees = compute_out_degree(nodes, forward_graph);
919 let caller_counts = get_caller_counts(nodes, reverse_graph);
920 let callee_counts = get_callee_counts(nodes, forward_graph);
921
922 let mut scores: Vec<HubScore> = nodes
923 .iter()
924 .map(|node| {
925 let in_deg = in_degrees.get(node).copied().unwrap_or(0.0);
926 let out_deg = out_degrees.get(node).copied().unwrap_or(0.0);
927 let callers = caller_counts.get(node).copied().unwrap_or(0);
928 let callees = callee_counts.get(node).copied().unwrap_or(0);
929
930 HubScore::new(node.clone(), in_deg, out_deg, callers, callees)
931 })
932 .collect();
933
934 scores.sort_by(|a, b| {
936 b.composite_score
937 .partial_cmp(&a.composite_score)
938 .unwrap_or(std::cmp::Ordering::Equal)
939 });
940
941 scores
942}
943
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
946#[serde(rename_all = "lowercase")]
947pub enum HubAlgorithm {
948 #[default]
950 All,
951 InDegree,
953 OutDegree,
955 PageRank,
957 Betweenness,
959}
960
961impl std::str::FromStr for HubAlgorithm {
962 type Err = String;
963
964 fn from_str(s: &str) -> Result<Self, Self::Err> {
965 match s.to_lowercase().as_str() {
966 "all" => Ok(HubAlgorithm::All),
967 "indegree" | "in_degree" | "in-degree" => Ok(HubAlgorithm::InDegree),
968 "outdegree" | "out_degree" | "out-degree" => Ok(HubAlgorithm::OutDegree),
969 "pagerank" | "page_rank" => Ok(HubAlgorithm::PageRank),
970 "betweenness" => Ok(HubAlgorithm::Betweenness),
971 _ => Err(format!(
972 "Unknown algorithm '{}'. Valid: all, indegree, outdegree, pagerank, betweenness",
973 s
974 )),
975 }
976 }
977}
978
979impl std::fmt::Display for HubAlgorithm {
980 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
981 match self {
982 HubAlgorithm::All => write!(f, "all"),
983 HubAlgorithm::InDegree => write!(f, "indegree"),
984 HubAlgorithm::OutDegree => write!(f, "outdegree"),
985 HubAlgorithm::PageRank => write!(f, "pagerank"),
986 HubAlgorithm::Betweenness => write!(f, "betweenness"),
987 }
988 }
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct HubReport {
994 pub hubs: Vec<HubScore>,
996 pub total_nodes: usize,
998 pub hub_count: usize,
1000 pub measures_used: Vec<String>,
1002 #[serde(skip_serializing_if = "Vec::is_empty")]
1004 pub by_in_degree: Vec<HubScore>,
1005 #[serde(skip_serializing_if = "Vec::is_empty")]
1007 pub by_out_degree: Vec<HubScore>,
1008 #[serde(skip_serializing_if = "Vec::is_empty")]
1010 pub by_pagerank: Vec<HubScore>,
1011 #[serde(skip_serializing_if = "Vec::is_empty")]
1013 pub by_betweenness: Vec<HubScore>,
1014 #[serde(skip_serializing_if = "Option::is_none")]
1016 pub pagerank_info: Option<PageRankConvergenceInfo>,
1017 #[serde(skip_serializing_if = "Option::is_none")]
1019 pub explanation: Option<String>,
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct PageRankConvergenceInfo {
1025 pub iterations_used: usize,
1027 pub converged: bool,
1029}
1030
1031impl From<&PageRankResult> for PageRankConvergenceInfo {
1032 fn from(result: &PageRankResult) -> Self {
1033 Self {
1034 iterations_used: result.iterations_used,
1035 converged: result.converged,
1036 }
1037 }
1038}
1039
1040pub fn compute_hub_scores_full(
1059 nodes: &HashSet<FunctionRef>,
1060 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
1061 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
1062 pagerank_config: Option<&PageRankConfig>,
1063 betweenness_config: Option<&BetweennessConfig>,
1064) -> (Vec<HubScore>, PageRankResult) {
1065 let in_degrees = compute_in_degree(nodes, reverse_graph);
1066 let out_degrees = compute_out_degree(nodes, forward_graph);
1067 let caller_counts = get_caller_counts(nodes, reverse_graph);
1068 let callee_counts = get_callee_counts(nodes, forward_graph);
1069
1070 let pr_config = pagerank_config.cloned().unwrap_or_default();
1072 let pagerank_result = compute_pagerank(nodes, reverse_graph, forward_graph, &pr_config);
1073
1074 let bc_config = betweenness_config.cloned().unwrap_or_default();
1076 let betweenness = compute_betweenness(nodes, forward_graph, &bc_config);
1077
1078 let mut scores: Vec<HubScore> = nodes
1079 .iter()
1080 .map(|node| {
1081 let in_deg = in_degrees.get(node).copied().unwrap_or(0.0);
1082 let out_deg = out_degrees.get(node).copied().unwrap_or(0.0);
1083 let pr = pagerank_result.scores.get(node).copied().unwrap_or(0.0);
1084 let bc = betweenness.get(node).copied().unwrap_or(0.0);
1085 let callers = caller_counts.get(node).copied().unwrap_or(0);
1086 let callees = callee_counts.get(node).copied().unwrap_or(0);
1087
1088 HubScore::with_all_measures(node.clone(), in_deg, out_deg, pr, bc, callers, callees)
1089 })
1090 .collect();
1091
1092 scores.sort_by(|a, b| {
1094 b.composite_score
1095 .partial_cmp(&a.composite_score)
1096 .unwrap_or(std::cmp::Ordering::Equal)
1097 });
1098
1099 (scores, pagerank_result)
1100}
1101
1102pub fn compute_hub_report(
1118 nodes: &HashSet<FunctionRef>,
1119 forward_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
1120 reverse_graph: &HashMap<FunctionRef, Vec<FunctionRef>>,
1121 algorithm: HubAlgorithm,
1122 top_k: usize,
1123 threshold: Option<f64>,
1124) -> HubReport {
1125 let total_nodes = nodes.len();
1126
1127 if total_nodes == 0 {
1129 return HubReport {
1130 hubs: Vec::new(),
1131 total_nodes: 0,
1132 hub_count: 0,
1133 measures_used: Vec::new(),
1134 by_in_degree: Vec::new(),
1135 by_out_degree: Vec::new(),
1136 by_pagerank: Vec::new(),
1137 by_betweenness: Vec::new(),
1138 pagerank_info: None,
1139 explanation: Some("Empty call graph - no functions found.".to_string()),
1140 };
1141 }
1142
1143 let in_degrees = compute_in_degree(nodes, reverse_graph);
1145 let out_degrees = compute_out_degree(nodes, forward_graph);
1146 let caller_counts = get_caller_counts(nodes, reverse_graph);
1147 let callee_counts = get_callee_counts(nodes, forward_graph);
1148
1149 let (pagerank_scores, pagerank_info) =
1151 if matches!(algorithm, HubAlgorithm::All | HubAlgorithm::PageRank) {
1152 let config = PageRankConfig::default();
1153 let result = compute_pagerank(nodes, reverse_graph, forward_graph, &config);
1154 let info = PageRankConvergenceInfo::from(&result);
1155 (Some(result.scores), Some(info))
1156 } else {
1157 (None, None)
1158 };
1159
1160 let betweenness_scores = if matches!(algorithm, HubAlgorithm::All | HubAlgorithm::Betweenness) {
1161 let config = BetweennessConfig::default();
1162 Some(compute_betweenness(nodes, forward_graph, &config))
1163 } else {
1164 None
1165 };
1166
1167 let mut all_scores: Vec<HubScore> = nodes
1169 .iter()
1170 .map(|node| {
1171 let in_deg = in_degrees.get(node).copied().unwrap_or(0.0);
1172 let out_deg = out_degrees.get(node).copied().unwrap_or(0.0);
1173 let pr = pagerank_scores.as_ref().and_then(|s| s.get(node).copied());
1174 let bc = betweenness_scores
1175 .as_ref()
1176 .and_then(|s| s.get(node).copied());
1177 let callers = caller_counts.get(node).copied().unwrap_or(0);
1178 let callees = callee_counts.get(node).copied().unwrap_or(0);
1179
1180 HubScore::with_optional_measures(
1181 node.clone(),
1182 in_deg,
1183 out_deg,
1184 pr,
1185 bc,
1186 callers,
1187 callees,
1188 )
1189 })
1190 .collect();
1191
1192 if let Some(thresh) = threshold {
1194 all_scores.retain(|s| s.composite_score >= thresh);
1195 }
1196
1197 all_scores.sort_by(|a, b| {
1199 b.composite_score
1200 .partial_cmp(&a.composite_score)
1201 .unwrap_or(std::cmp::Ordering::Equal)
1202 });
1203
1204 let hubs: Vec<HubScore> = all_scores.into_iter().take(top_k).collect();
1206 let hub_count = hubs.len();
1207
1208 let (by_in_degree, by_out_degree, by_pagerank, by_betweenness) =
1210 if matches!(algorithm, HubAlgorithm::All) {
1211 let mut by_in: Vec<HubScore> = hubs.clone();
1213 by_in.sort_by(|a, b| {
1214 b.in_degree
1215 .partial_cmp(&a.in_degree)
1216 .unwrap_or(std::cmp::Ordering::Equal)
1217 });
1218
1219 let mut by_out: Vec<HubScore> = hubs.clone();
1220 by_out.sort_by(|a, b| {
1221 b.out_degree
1222 .partial_cmp(&a.out_degree)
1223 .unwrap_or(std::cmp::Ordering::Equal)
1224 });
1225
1226 let mut by_pr: Vec<HubScore> = hubs.clone();
1227 by_pr.sort_by(|a, b| {
1228 let a_pr = a.pagerank.unwrap_or(0.0);
1229 let b_pr = b.pagerank.unwrap_or(0.0);
1230 b_pr.partial_cmp(&a_pr).unwrap_or(std::cmp::Ordering::Equal)
1231 });
1232
1233 let mut by_bc: Vec<HubScore> = hubs.clone();
1234 by_bc.sort_by(|a, b| {
1235 let a_bc = a.betweenness.unwrap_or(0.0);
1236 let b_bc = b.betweenness.unwrap_or(0.0);
1237 b_bc.partial_cmp(&a_bc).unwrap_or(std::cmp::Ordering::Equal)
1238 });
1239
1240 (by_in, by_out, by_pr, by_bc)
1241 } else {
1242 (Vec::new(), Vec::new(), Vec::new(), Vec::new())
1243 };
1244
1245 let measures_used = match algorithm {
1247 HubAlgorithm::All => vec![
1248 "in_degree".to_string(),
1249 "out_degree".to_string(),
1250 "pagerank".to_string(),
1251 "betweenness".to_string(),
1252 ],
1253 HubAlgorithm::InDegree => vec!["in_degree".to_string()],
1254 HubAlgorithm::OutDegree => vec!["out_degree".to_string()],
1255 HubAlgorithm::PageRank => vec!["pagerank".to_string()],
1256 HubAlgorithm::Betweenness => vec!["betweenness".to_string()],
1257 };
1258
1259 let explanation = if total_nodes < 10 {
1261 Some(format!(
1262 "Small call graph ({} nodes). Hub metrics may not be statistically meaningful for graphs with fewer than 10 nodes.",
1263 total_nodes
1264 ))
1265 } else {
1266 let critical_count = hubs
1268 .iter()
1269 .filter(|h| h.risk_level == RiskLevel::Critical)
1270 .count();
1271 let high_count = hubs
1272 .iter()
1273 .filter(|h| h.risk_level == RiskLevel::High)
1274 .count();
1275 if critical_count > 0 || high_count > 0 {
1276 Some(format!(
1277 "Found {} critical and {} high-risk hubs. Changes to these functions may have widespread impact.",
1278 critical_count, high_count
1279 ))
1280 } else {
1281 None
1282 }
1283 };
1284
1285 HubReport {
1286 hubs,
1287 total_nodes,
1288 hub_count,
1289 measures_used,
1290 by_in_degree,
1291 by_out_degree,
1292 by_pagerank,
1293 by_betweenness,
1294 pagerank_info,
1295 explanation,
1296 }
1297}
1298
1299#[cfg(test)]
1304mod tests {
1305 use super::*;
1306 use crate::callgraph::graph_utils::{build_forward_graph, build_reverse_graph, collect_nodes};
1307 use crate::types::{CallEdge, ProjectCallGraph};
1308
1309 fn create_star_graph() -> ProjectCallGraph {
1311 let mut graph = ProjectCallGraph::new();
1312
1313 for i in 1..=5 {
1315 graph.add_edge(CallEdge {
1316 src_file: PathBuf::from(format!("caller_{}.py", i)),
1317 src_func: format!("caller_{}", i),
1318 dst_file: PathBuf::from("hub.py"),
1319 dst_func: "central_hub".to_string(),
1320 });
1321 }
1322
1323 graph
1324 }
1325
1326 fn create_chain_graph() -> ProjectCallGraph {
1328 let mut graph = ProjectCallGraph::new();
1329
1330 graph.add_edge(CallEdge {
1331 src_file: PathBuf::from("a.py"),
1332 src_func: "func_a".to_string(),
1333 dst_file: PathBuf::from("b.py"),
1334 dst_func: "func_b".to_string(),
1335 });
1336
1337 graph.add_edge(CallEdge {
1338 src_file: PathBuf::from("b.py"),
1339 src_func: "func_b".to_string(),
1340 dst_file: PathBuf::from("c.py"),
1341 dst_func: "func_c".to_string(),
1342 });
1343
1344 graph.add_edge(CallEdge {
1345 src_file: PathBuf::from("c.py"),
1346 src_func: "func_c".to_string(),
1347 dst_file: PathBuf::from("d.py"),
1348 dst_func: "func_d".to_string(),
1349 });
1350
1351 graph
1352 }
1353
1354 fn create_diamond_graph() -> ProjectCallGraph {
1356 let mut graph = ProjectCallGraph::new();
1357
1358 graph.add_edge(CallEdge {
1360 src_file: PathBuf::from("a.py"),
1361 src_func: "func_a".to_string(),
1362 dst_file: PathBuf::from("b.py"),
1363 dst_func: "func_b".to_string(),
1364 });
1365
1366 graph.add_edge(CallEdge {
1368 src_file: PathBuf::from("a.py"),
1369 src_func: "func_a".to_string(),
1370 dst_file: PathBuf::from("c.py"),
1371 dst_func: "func_c".to_string(),
1372 });
1373
1374 graph.add_edge(CallEdge {
1376 src_file: PathBuf::from("b.py"),
1377 src_func: "func_b".to_string(),
1378 dst_file: PathBuf::from("d.py"),
1379 dst_func: "func_d".to_string(),
1380 });
1381
1382 graph.add_edge(CallEdge {
1384 src_file: PathBuf::from("c.py"),
1385 src_func: "func_c".to_string(),
1386 dst_file: PathBuf::from("d.py"),
1387 dst_func: "func_d".to_string(),
1388 });
1389
1390 graph
1391 }
1392
1393 #[test]
1398 fn test_risk_level_thresholds() {
1399 assert_eq!(RiskLevel::from_score(0.9), RiskLevel::Critical);
1400 assert_eq!(RiskLevel::from_score(0.8), RiskLevel::Critical);
1401 assert_eq!(RiskLevel::from_score(0.79), RiskLevel::High);
1402 assert_eq!(RiskLevel::from_score(0.6), RiskLevel::High);
1403 assert_eq!(RiskLevel::from_score(0.59), RiskLevel::Medium);
1404 assert_eq!(RiskLevel::from_score(0.4), RiskLevel::Medium);
1405 assert_eq!(RiskLevel::from_score(0.39), RiskLevel::Low);
1406 assert_eq!(RiskLevel::from_score(0.0), RiskLevel::Low);
1407 }
1408
1409 #[test]
1410 fn test_risk_level_edge_cases() {
1411 assert_eq!(RiskLevel::from_score(1.0), RiskLevel::Critical);
1413 assert_eq!(RiskLevel::from_score(0.0), RiskLevel::Low);
1414 }
1415
1416 #[test]
1421 fn indegree_counts_callers() {
1422 let graph = create_star_graph();
1423 let reverse = build_reverse_graph(&graph);
1424 let nodes = collect_nodes(&graph);
1425
1426 let in_degrees = compute_in_degree(&nodes, &reverse);
1427
1428 let hub = FunctionRef::new(PathBuf::from("hub.py"), "central_hub");
1432 assert_eq!(in_degrees.get(&hub), Some(&1.0));
1433
1434 for i in 1..=5 {
1436 let caller = FunctionRef::new(
1437 PathBuf::from(format!("caller_{}.py", i)),
1438 format!("caller_{}", i),
1439 );
1440 assert_eq!(in_degrees.get(&caller), Some(&0.0));
1441 }
1442 }
1443
1444 #[test]
1445 fn indegree_normalized() {
1446 let graph = create_diamond_graph();
1447 let reverse = build_reverse_graph(&graph);
1448 let nodes = collect_nodes(&graph);
1449
1450 let in_degrees = compute_in_degree(&nodes, &reverse);
1451
1452 for °ree in in_degrees.values() {
1454 assert!(
1455 (0.0..=1.0).contains(°ree),
1456 "In-degree {} out of range",
1457 degree
1458 );
1459 }
1460
1461 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1464 let d_degree = in_degrees.get(&d).unwrap();
1465 assert!((d_degree - 2.0 / 3.0).abs() < 0.001);
1466
1467 let b = FunctionRef::new(PathBuf::from("b.py"), "func_b");
1470 let c = FunctionRef::new(PathBuf::from("c.py"), "func_c");
1471 assert!((in_degrees.get(&b).unwrap() - 1.0 / 3.0).abs() < 0.001);
1472 assert!((in_degrees.get(&c).unwrap() - 1.0 / 3.0).abs() < 0.001);
1473
1474 let a = FunctionRef::new(PathBuf::from("a.py"), "func_a");
1476 assert_eq!(in_degrees.get(&a), Some(&0.0));
1477 }
1478
1479 #[test]
1484 fn outdegree_counts_callees() {
1485 let graph = create_diamond_graph();
1486 let forward = build_forward_graph(&graph);
1487 let nodes = collect_nodes(&graph);
1488
1489 let out_degrees = compute_out_degree(&nodes, &forward);
1490
1491 let a = FunctionRef::new(PathBuf::from("a.py"), "func_a");
1494 let a_degree = out_degrees.get(&a).unwrap();
1495 assert!((a_degree - 2.0 / 3.0).abs() < 0.001);
1496
1497 let b = FunctionRef::new(PathBuf::from("b.py"), "func_b");
1500 let c = FunctionRef::new(PathBuf::from("c.py"), "func_c");
1501 assert!((out_degrees.get(&b).unwrap() - 1.0 / 3.0).abs() < 0.001);
1502 assert!((out_degrees.get(&c).unwrap() - 1.0 / 3.0).abs() < 0.001);
1503
1504 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1506 assert_eq!(out_degrees.get(&d), Some(&0.0));
1507 }
1508
1509 #[test]
1510 fn outdegree_normalized() {
1511 let graph = create_chain_graph();
1512 let forward = build_forward_graph(&graph);
1513 let nodes = collect_nodes(&graph);
1514
1515 let out_degrees = compute_out_degree(&nodes, &forward);
1516
1517 for °ree in out_degrees.values() {
1519 assert!(
1520 (0.0..=1.0).contains(°ree),
1521 "Out-degree {} out of range",
1522 degree
1523 );
1524 }
1525
1526 for name in ["func_a", "func_b", "func_c"] {
1530 let file = format!("{}.py", name.chars().last().unwrap());
1531 let func = FunctionRef::new(PathBuf::from(&file), name);
1532 let degree = out_degrees.get(&func).unwrap();
1533 assert!(
1534 (degree - 1.0 / 3.0).abs() < 0.001,
1535 "{} has unexpected out-degree {}",
1536 name,
1537 degree
1538 );
1539 }
1540
1541 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1543 assert_eq!(out_degrees.get(&d), Some(&0.0));
1544 }
1545
1546 #[test]
1551 fn empty_graph_returns_empty_map() {
1552 let graph = ProjectCallGraph::new();
1553 let forward = build_forward_graph(&graph);
1554 let reverse = build_reverse_graph(&graph);
1555 let nodes = collect_nodes(&graph);
1556
1557 let in_degrees = compute_in_degree(&nodes, &reverse);
1558 let out_degrees = compute_out_degree(&nodes, &forward);
1559
1560 assert!(in_degrees.is_empty());
1561 assert!(out_degrees.is_empty());
1562 }
1563
1564 #[test]
1565 fn single_node_graph() {
1566 let mut graph = ProjectCallGraph::new();
1568 graph.add_edge(CallEdge {
1569 src_file: PathBuf::from("a.py"),
1570 src_func: "func_a".to_string(),
1571 dst_file: PathBuf::from("a.py"),
1572 dst_func: "func_a".to_string(),
1573 });
1574
1575 let forward = build_forward_graph(&graph);
1576 let reverse = build_reverse_graph(&graph);
1577 let nodes = collect_nodes(&graph);
1578
1579 assert_eq!(nodes.len(), 1);
1580
1581 let in_degrees = compute_in_degree(&nodes, &reverse);
1582 let out_degrees = compute_out_degree(&nodes, &forward);
1583
1584 let a = FunctionRef::new(PathBuf::from("a.py"), "func_a");
1587 assert_eq!(in_degrees.get(&a), Some(&0.0));
1588 assert_eq!(out_degrees.get(&a), Some(&0.0));
1589 }
1590
1591 #[test]
1592 fn two_node_graph_normalization() {
1593 let mut graph = ProjectCallGraph::new();
1595 graph.add_edge(CallEdge {
1596 src_file: PathBuf::from("a.py"),
1597 src_func: "func_a".to_string(),
1598 dst_file: PathBuf::from("b.py"),
1599 dst_func: "func_b".to_string(),
1600 });
1601
1602 let forward = build_forward_graph(&graph);
1603 let reverse = build_reverse_graph(&graph);
1604 let nodes = collect_nodes(&graph);
1605
1606 assert_eq!(nodes.len(), 2);
1607
1608 let in_degrees = compute_in_degree(&nodes, &reverse);
1609 let out_degrees = compute_out_degree(&nodes, &forward);
1610
1611 let a = FunctionRef::new(PathBuf::from("a.py"), "func_a");
1612 let b = FunctionRef::new(PathBuf::from("b.py"), "func_b");
1613
1614 assert_eq!(in_degrees.get(&a), Some(&0.0));
1618 assert_eq!(out_degrees.get(&a), Some(&1.0));
1619 assert_eq!(in_degrees.get(&b), Some(&1.0));
1620 assert_eq!(out_degrees.get(&b), Some(&0.0));
1621 }
1622
1623 #[test]
1628 fn hub_score_composite_calculation() {
1629 let func = FunctionRef::new(PathBuf::from("test.py"), "test_func");
1630 let score = HubScore::new(func, 0.6, 0.4, 3, 2);
1631
1632 assert!((score.composite_score - 0.5).abs() < 0.001);
1634 assert_eq!(score.risk_level, RiskLevel::Medium);
1635 }
1636
1637 #[test]
1638 fn hub_score_critical_threshold() {
1639 let func = FunctionRef::new(PathBuf::from("test.py"), "critical_func");
1640 let score = HubScore::new(func, 0.9, 0.8, 10, 8);
1641
1642 assert!(score.composite_score >= 0.8);
1644 assert_eq!(score.risk_level, RiskLevel::Critical);
1645 }
1646
1647 #[test]
1648 fn compute_hub_scores_sorting() {
1649 let graph = create_star_graph();
1650 let forward = build_forward_graph(&graph);
1651 let reverse = build_reverse_graph(&graph);
1652 let nodes = collect_nodes(&graph);
1653
1654 let scores = compute_hub_scores(&nodes, &forward, &reverse);
1655
1656 for window in scores.windows(2) {
1658 assert!(
1659 window[0].composite_score >= window[1].composite_score,
1660 "Scores not sorted: {} >= {}",
1661 window[0].composite_score,
1662 window[1].composite_score
1663 );
1664 }
1665
1666 assert_eq!(scores[0].name, "central_hub");
1669 }
1670
1671 #[test]
1672 fn compute_hub_scores_includes_raw_counts() {
1673 let graph = create_star_graph();
1674 let forward = build_forward_graph(&graph);
1675 let reverse = build_reverse_graph(&graph);
1676 let nodes = collect_nodes(&graph);
1677
1678 let scores = compute_hub_scores(&nodes, &forward, &reverse);
1679
1680 let hub_score = scores.iter().find(|s| s.name == "central_hub").unwrap();
1682 assert_eq!(hub_score.callers_count, 5);
1683 assert_eq!(hub_score.callees_count, 0);
1684
1685 let caller_score = scores.iter().find(|s| s.name == "caller_1").unwrap();
1687 assert_eq!(caller_score.callers_count, 0);
1688 assert_eq!(caller_score.callees_count, 1);
1689 }
1690
1691 #[test]
1696 fn pagerank_converges() {
1697 let graph = create_diamond_graph();
1699 let forward = build_forward_graph(&graph);
1700 let reverse = build_reverse_graph(&graph);
1701 let nodes = collect_nodes(&graph);
1702
1703 let config = PageRankConfig::default();
1704 let result = compute_pagerank(&nodes, &reverse, &forward, &config);
1705
1706 assert!(result.converged, "PageRank did not converge");
1708 assert!(
1709 result.iterations_used < config.max_iterations,
1710 "Used all iterations: {}",
1711 result.iterations_used
1712 );
1713
1714 for &score in result.scores.values() {
1716 assert!((0.0..=1.0).contains(&score), "Score {} out of range", score);
1717 }
1718
1719 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1721 let d_score = result.scores.get(&d).copied().unwrap_or(0.0);
1722
1723 assert!(
1725 (d_score - 1.0).abs() < 0.01,
1726 "D should have highest PR, got {}",
1727 d_score
1728 );
1729 }
1730
1731 #[test]
1732 fn pagerank_damping_factor() {
1733 let graph = create_chain_graph();
1734 let forward = build_forward_graph(&graph);
1735 let reverse = build_reverse_graph(&graph);
1736 let nodes = collect_nodes(&graph);
1737
1738 let config_high = PageRankConfig {
1740 damping: 0.95,
1741 ..Default::default()
1742 };
1743 let config_low = PageRankConfig {
1744 damping: 0.5,
1745 ..Default::default()
1746 };
1747
1748 let result_high = compute_pagerank(&nodes, &reverse, &forward, &config_high);
1749 let result_low = compute_pagerank(&nodes, &reverse, &forward, &config_low);
1750
1751 assert!(result_high.converged);
1753 assert!(result_low.converged);
1754
1755 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1758
1759 let d_high = result_high.scores.get(&d).copied().unwrap_or(0.0);
1760 let d_low = result_low.scores.get(&d).copied().unwrap_or(0.0);
1761
1762 assert!(d_high > 0.0 && d_low > 0.0);
1766 }
1767
1768 #[test]
1769 fn pagerank_handles_dangling_nodes() {
1770 let graph = create_chain_graph();
1772 let forward = build_forward_graph(&graph);
1773 let reverse = build_reverse_graph(&graph);
1774 let nodes = collect_nodes(&graph);
1775
1776 let config = PageRankConfig::default();
1777 let result = compute_pagerank(&nodes, &reverse, &forward, &config);
1778
1779 assert!(
1781 result.converged,
1782 "PageRank did not converge with dangling node"
1783 );
1784
1785 for (node, &score) in &result.scores {
1787 assert!(!score.is_nan(), "NaN score for {:?}", node);
1788 assert!(!score.is_infinite(), "Infinite score for {:?}", node);
1789 assert!(score >= 0.0, "Negative score for {:?}", node);
1790 }
1791
1792 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1794 let d_score = result.scores.get(&d).copied().unwrap_or(-1.0);
1795 assert!(d_score > 0.0, "Dangling node should have positive PR");
1796 }
1797
1798 #[test]
1799 fn pagerank_empty_graph() {
1800 let nodes: HashSet<FunctionRef> = HashSet::new();
1801 let forward: HashMap<FunctionRef, Vec<FunctionRef>> = HashMap::new();
1802 let reverse: HashMap<FunctionRef, Vec<FunctionRef>> = HashMap::new();
1803
1804 let config = PageRankConfig::default();
1805 let result = compute_pagerank(&nodes, &reverse, &forward, &config);
1806
1807 assert!(result.scores.is_empty());
1808 assert!(result.converged);
1809 assert_eq!(result.iterations_used, 0);
1810 }
1811
1812 #[test]
1813 fn pagerank_single_node() {
1814 let node = FunctionRef::new(PathBuf::from("single.py"), "single_func");
1815 let mut nodes: HashSet<FunctionRef> = HashSet::new();
1816 nodes.insert(node.clone());
1817
1818 let forward: HashMap<FunctionRef, Vec<FunctionRef>> = HashMap::new();
1819 let reverse: HashMap<FunctionRef, Vec<FunctionRef>> = HashMap::new();
1820
1821 let config = PageRankConfig::default();
1822 let result = compute_pagerank(&nodes, &reverse, &forward, &config);
1823
1824 assert_eq!(result.scores.len(), 1);
1825 assert_eq!(result.scores.get(&node), Some(&1.0));
1826 assert!(result.converged);
1827 }
1828
1829 #[test]
1834 fn betweenness_bridge_detection() {
1835 let graph = create_chain_graph();
1838 let forward = build_forward_graph(&graph);
1839 let nodes = collect_nodes(&graph);
1840
1841 let config = BetweennessConfig::default();
1842 let betweenness = compute_betweenness(&nodes, &forward, &config);
1843
1844 for &score in betweenness.values() {
1846 assert!(
1847 (0.0..=1.0).contains(&score),
1848 "Betweenness {} out of range",
1849 score
1850 );
1851 }
1852
1853 let b = FunctionRef::new(PathBuf::from("b.py"), "func_b");
1855 let c = FunctionRef::new(PathBuf::from("c.py"), "func_c");
1856 let a = FunctionRef::new(PathBuf::from("a.py"), "func_a");
1857 let d = FunctionRef::new(PathBuf::from("d.py"), "func_d");
1858
1859 let b_bc = betweenness.get(&b).copied().unwrap_or(0.0);
1860 let c_bc = betweenness.get(&c).copied().unwrap_or(0.0);
1861 let a_bc = betweenness.get(&a).copied().unwrap_or(0.0);
1862 let d_bc = betweenness.get(&d).copied().unwrap_or(0.0);
1863
1864 assert!(
1867 b_bc >= a_bc,
1868 "B should have >= betweenness than A (endpoint)"
1869 );
1870 assert!(
1871 c_bc >= d_bc,
1872 "C should have >= betweenness than D (endpoint)"
1873 );
1874 }
1875
1876 #[test]
1877 fn betweenness_normalized() {
1878 let graph = create_diamond_graph();
1880 let forward = build_forward_graph(&graph);
1881 let nodes = collect_nodes(&graph);
1882
1883 let config = BetweennessConfig::default();
1884 let betweenness = compute_betweenness(&nodes, &forward, &config);
1885
1886 for (node, &score) in &betweenness {
1888 assert!(
1889 (0.0..=1.0).contains(&score),
1890 "Betweenness for {:?} = {} is not in [0,1]",
1891 node,
1892 score
1893 );
1894 assert!(!score.is_nan(), "NaN betweenness for {:?}", node);
1895 assert!(!score.is_infinite(), "Infinite betweenness for {:?}", node);
1896 }
1897 }
1898
1899 #[test]
1900 fn betweenness_sampling() {
1901 let mut graph = ProjectCallGraph::new();
1903
1904 for i in 0..9 {
1906 graph.add_edge(CallEdge {
1907 src_file: PathBuf::from(format!("node_{}.py", i)),
1908 src_func: format!("func_{}", i),
1909 dst_file: PathBuf::from(format!("node_{}.py", i + 1)),
1910 dst_func: format!("func_{}", i + 1),
1911 });
1912 }
1913
1914 let forward = build_forward_graph(&graph);
1915 let nodes = collect_nodes(&graph);
1916
1917 let config_full = BetweennessConfig {
1919 sample_size: None,
1920 max_nodes: 5000,
1921 };
1922 let bc_full = compute_betweenness(&nodes, &forward, &config_full);
1923
1924 let config_sampled = BetweennessConfig {
1926 sample_size: Some(5),
1927 max_nodes: 5000,
1928 };
1929 let bc_sampled = compute_betweenness(&nodes, &forward, &config_sampled);
1930
1931 for &score in bc_full.values() {
1933 assert!((0.0..=1.0).contains(&score));
1934 }
1935 for &score in bc_sampled.values() {
1936 assert!((0.0..=1.0).contains(&score));
1937 }
1938
1939 let mid = FunctionRef::new(PathBuf::from("node_5.py"), "func_5");
1941 assert!(bc_full.get(&mid).copied().unwrap_or(0.0) > 0.0);
1942 }
1945
1946 #[test]
1947 fn betweenness_small_graph() {
1948 let mut graph = ProjectCallGraph::new();
1950 graph.add_edge(CallEdge {
1951 src_file: PathBuf::from("a.py"),
1952 src_func: "func_a".to_string(),
1953 dst_file: PathBuf::from("b.py"),
1954 dst_func: "func_b".to_string(),
1955 });
1956
1957 let forward = build_forward_graph(&graph);
1958 let nodes = collect_nodes(&graph);
1959
1960 assert_eq!(nodes.len(), 2);
1961
1962 let config = BetweennessConfig::default();
1963 let betweenness = compute_betweenness(&nodes, &forward, &config);
1964
1965 for &score in betweenness.values() {
1967 assert_eq!(score, 0.0);
1968 }
1969 }
1970
1971 #[test]
1976 fn composite_weighted_average() {
1977 let in_deg = 0.4;
1979 let out_deg = 0.3;
1980 let pr = 0.5;
1981 let bc = 0.6;
1982
1983 let composite = compute_composite_score(in_deg, out_deg, Some(pr), Some(bc));
1987
1988 let expected = WEIGHT_IN_DEGREE * in_deg
1989 + WEIGHT_OUT_DEGREE * out_deg
1990 + WEIGHT_PAGERANK * pr
1991 + WEIGHT_BETWEENNESS * bc;
1992
1993 assert!(
1994 (composite - expected).abs() < 0.001,
1995 "Expected {}, got {}",
1996 expected,
1997 composite
1998 );
1999 }
2000
2001 #[test]
2002 fn composite_partial_measures() {
2003 let in_deg = 0.6;
2005 let out_deg = 0.4;
2006
2007 let composite = compute_composite_score(in_deg, out_deg, None, None);
2008
2009 let expected = (WEIGHT_IN_DEGREE * in_deg + WEIGHT_OUT_DEGREE * out_deg)
2011 / (WEIGHT_IN_DEGREE + WEIGHT_OUT_DEGREE);
2012
2013 assert!(
2014 (composite - expected).abs() < 0.001,
2015 "Expected {}, got {}",
2016 expected,
2017 composite
2018 );
2019 }
2020
2021 #[test]
2022 fn hub_score_with_all_measures() {
2023 let func = FunctionRef::new(PathBuf::from("test.py"), "test_func");
2024 let score = HubScore::with_all_measures(
2025 func, 0.4, 0.3, 0.5, 0.6, 5, 4, );
2032
2033 assert_eq!(score.pagerank, Some(0.5));
2034 assert_eq!(score.betweenness, Some(0.6));
2035 assert_eq!(score.callers_count, 5);
2036 assert_eq!(score.callees_count, 4);
2037
2038 let expected_composite = compute_composite_score(0.4, 0.3, Some(0.5), Some(0.6));
2040 assert!((score.composite_score - expected_composite).abs() < 0.001);
2041 }
2042
2043 #[test]
2044 fn compute_hub_scores_full_integration() {
2045 let graph = create_diamond_graph();
2046 let forward = build_forward_graph(&graph);
2047 let reverse = build_reverse_graph(&graph);
2048 let nodes = collect_nodes(&graph);
2049
2050 let (scores, pr_result) = compute_hub_scores_full(&nodes, &forward, &reverse, None, None);
2051
2052 assert!(pr_result.converged);
2054
2055 for score in &scores {
2057 assert!(
2058 score.pagerank.is_some(),
2059 "Missing pagerank for {}",
2060 score.name
2061 );
2062 assert!(
2063 score.betweenness.is_some(),
2064 "Missing betweenness for {}",
2065 score.name
2066 );
2067 assert!(score.in_degree >= 0.0 && score.in_degree <= 1.0);
2068 assert!(score.out_degree >= 0.0 && score.out_degree <= 1.0);
2069 }
2070
2071 for window in scores.windows(2) {
2073 assert!(
2074 window[0].composite_score >= window[1].composite_score,
2075 "Not sorted: {} >= {}",
2076 window[0].composite_score,
2077 window[1].composite_score
2078 );
2079 }
2080 }
2081}