Skip to main content

hotspots_core/
callgraph.rs

1//! Call graph extraction and analysis
2//!
3//! Extracts function call relationships and computes graph-based metrics:
4//! - Fan-in/fan-out (structural coupling)
5//! - PageRank (importance/centrality)
6//! - Betweenness centrality (critical paths)
7//!
8//! ## Limitations (by design)
9//!
10//! This implementation tracks **internal function calls only** (functions defined
11//! in the analyzed codebase). External calls are intentionally excluded:
12//!
13//! - ❌ External library calls (npm packages, standard libraries)
14//! - ❌ Dynamic/runtime calls (callbacks, reflection, dynamic imports)
15//! - ❌ Indirect calls through function pointers or event handlers
16//!
17//! This keeps analysis fast, deterministic, and focused on the codebase's internal
18//! architecture. Advanced call tracking (including external dependencies and runtime
19//! analysis) is reserved for future cloud/pro versions.
20
21use std::collections::{HashMap, VecDeque};
22
23/// Call graph for a codebase.
24///
25/// Uses an index-based representation: node strings are interned into a `Vec<String>`
26/// and all adjacency, BFS, and PageRank structures operate on `u32` indices.
27/// This avoids per-BFS-call HashMap<String, ...> allocations that otherwise grow
28/// unbounded under approximate betweenness (k=256 calls × ~400 MB/call).
29#[derive(Debug, Clone)]
30pub struct CallGraph {
31    ids: Vec<String>,
32    id_to_idx: HashMap<String, u32>,
33    adj: Vec<Vec<u32>>,
34    /// Total callee names found in ASTs across all functions
35    pub total_callee_names: usize,
36    /// Callee names that resolved to a known internal function ID
37    pub resolved_callee_names: usize,
38}
39
40/// Graph metrics for a single function
41#[derive(Debug, Clone, PartialEq)]
42pub struct GraphMetrics {
43    /// Fan-in: number of functions calling this function
44    pub fan_in: usize,
45    /// Fan-out: number of functions this function calls
46    pub fan_out: usize,
47    /// PageRank score (importance/centrality)
48    pub pagerank: f64,
49    /// Betweenness centrality (criticality on paths)
50    pub betweenness: f64,
51}
52
53impl CallGraph {
54    /// Create an empty call graph
55    pub fn new() -> Self {
56        CallGraph {
57            ids: Vec::new(),
58            id_to_idx: HashMap::new(),
59            adj: Vec::new(),
60            total_callee_names: 0,
61            resolved_callee_names: 0,
62        }
63    }
64
65    /// Intern a node string, returning its u32 index (allocated if new).
66    pub fn intern(&mut self, id: String) -> u32 {
67        if let Some(&idx) = self.id_to_idx.get(&id) {
68            return idx;
69        }
70        let idx = self.ids.len() as u32;
71        self.id_to_idx.insert(id.clone(), idx);
72        self.ids.push(id);
73        self.adj.push(Vec::new());
74        idx
75    }
76
77    /// Number of nodes in the graph.
78    pub fn node_count(&self) -> usize {
79        self.ids.len()
80    }
81
82    /// Total number of directed edges in the graph.
83    pub fn edge_count(&self) -> usize {
84        self.adj.iter().map(|v| v.len()).sum()
85    }
86
87    /// Returns true if `id` is a node in this graph.
88    pub fn contains(&self, id: &str) -> bool {
89        self.id_to_idx.contains_key(id)
90    }
91
92    /// Returns an iterator over callee IDs for the given function, or None if not found.
93    pub fn callees_of<'a>(&'a self, id: &str) -> Option<impl Iterator<Item = &'a str>> {
94        let idx = *self.id_to_idx.get(id)? as usize;
95        Some(self.adj[idx].iter().map(|&i| self.ids[i as usize].as_str()))
96    }
97
98    /// Add a directed edge from `caller_idx` to `callee_idx` (index-based, no interning).
99    ///
100    /// Both indices must already be interned. Used by `lib.rs` during fast graph construction
101    /// to avoid redundant string lookups after `intern` has already been called.
102    pub fn add_adj(&mut self, caller_idx: u32, callee_idx: u32) {
103        self.adj[caller_idx as usize].push(callee_idx);
104    }
105
106    /// Iterate over all interned function IDs in the graph.
107    pub fn all_ids(&self) -> impl Iterator<Item = &str> {
108        self.ids.iter().map(|s| s.as_str())
109    }
110
111    /// Add a function to the graph (interning its ID).
112    pub fn add_node(&mut self, function_id: String) {
113        self.intern(function_id);
114    }
115
116    /// Add a call edge (caller -> callee), interning both nodes.
117    pub fn add_edge(&mut self, caller: String, callee: String) {
118        let caller_idx = self.intern(caller);
119        let callee_idx = self.intern(callee);
120        self.adj[caller_idx as usize].push(callee_idx);
121    }
122
123    /// Calculate fan-in for a function (number of callers).
124    pub fn fan_in(&self, function_id: &str) -> usize {
125        match self.id_to_idx.get(function_id) {
126            None => 0,
127            Some(&target) => self.adj.iter().filter(|c| c.contains(&target)).count(),
128        }
129    }
130
131    /// Calculate fan-out for a function (number of callees).
132    pub fn fan_out(&self, function_id: &str) -> usize {
133        match self.id_to_idx.get(function_id) {
134            None => 0,
135            Some(&idx) => self.adj[idx as usize].len(),
136        }
137    }
138
139    /// Calculate PageRank for all functions.
140    ///
141    /// Uses Vec<f64> indexed by node index with swap-buffer iteration — no per-iteration
142    /// HashMap allocations.
143    ///
144    /// # Arguments
145    ///
146    /// * `damping` - Damping factor (typically 0.85)
147    /// * `max_iterations` - Upper bound on iterations (typically 20-50)
148    /// * `epsilon` - Convergence threshold; stop early when max delta < epsilon (e.g. 1e-6)
149    pub fn pagerank(
150        &self,
151        damping: f64,
152        max_iterations: usize,
153        epsilon: f64,
154    ) -> HashMap<String, f64> {
155        let n = self.ids.len();
156        if n == 0 {
157            return HashMap::new();
158        }
159
160        // Build reverse adjacency once
161        let mut rev_adj: Vec<Vec<u32>> = vec![Vec::new(); n];
162        for (caller_idx, callees) in self.adj.iter().enumerate() {
163            for &callee_idx in callees {
164                rev_adj[callee_idx as usize].push(caller_idx as u32);
165            }
166        }
167        // Sort caller lists for deterministic computation
168        for callers in rev_adj.iter_mut() {
169            callers.sort();
170        }
171
172        // fan_out per node (clamped to 1 to avoid divide-by-zero)
173        let fan_out: Vec<f64> = self.adj.iter().map(|v| v.len().max(1) as f64).collect();
174
175        let initial_rank = 1.0 / n as f64;
176        let mut ranks = vec![initial_rank; n];
177        let mut new_ranks = vec![0.0f64; n];
178
179        for _ in 0..max_iterations {
180            for i in 0..n {
181                let mut rank = (1.0 - damping) / n as f64;
182                for &caller_idx in &rev_adj[i] {
183                    rank += damping * ranks[caller_idx as usize] / fan_out[caller_idx as usize];
184                }
185                new_ranks[i] = rank;
186            }
187
188            let max_delta = ranks
189                .iter()
190                .zip(new_ranks.iter())
191                .map(|(old, new)| (new - old).abs())
192                .fold(0.0_f64, f64::max);
193
194            std::mem::swap(&mut ranks, &mut new_ranks);
195
196            if max_delta < epsilon {
197                break;
198            }
199        }
200
201        self.ids
202            .iter()
203            .enumerate()
204            .map(|(i, id)| (id.clone(), ranks[i]))
205            .collect()
206    }
207
208    /// Calculate betweenness centrality using pivoted source sampling (approximate).
209    ///
210    /// Uses systematic k-source sampling: selects k sources evenly spaced through the
211    /// sorted node list and scales contributions by N/k. This gives an unbiased estimator
212    /// of exact betweenness with O(k × (N+E)) complexity instead of O(N × (N+E)).
213    ///
214    /// Falls back to exact computation when `self.node_count() <= k`.
215    ///
216    /// BFS working buffers (stack, pred, sigma, dist, delta) are pre-allocated once and
217    /// reused across all k iterations via `.clear()` / `.fill()` — RSS is flat regardless
218    /// of k.
219    ///
220    /// # Arguments
221    ///
222    /// * `k` - Number of pivot sources to sample (higher = more accurate, more time)
223    pub fn betweenness_centrality_approx(&self, k: usize) -> HashMap<String, f64> {
224        let n = self.ids.len();
225        if n <= k {
226            return self.betweenness_centrality();
227        }
228
229        // Sort node indices by string ID for deterministic stride-based sampling
230        let mut sorted_indices: Vec<u32> = (0..n as u32).collect();
231        sorted_indices.sort_by_key(|&i| &self.ids[i as usize]);
232
233        let scale = n as f64 / k as f64;
234        let mut betweenness = vec![0.0f64; n];
235
236        // Pre-allocate BFS buffers — reused every iteration (no dealloc between calls)
237        let mut stack: Vec<u32> = Vec::with_capacity(n);
238        let mut pred: Vec<Vec<u32>> = vec![Vec::new(); n];
239        let mut sigma = vec![0.0f64; n];
240        let mut dist = vec![-1i32; n];
241        let mut delta = vec![0.0f64; n];
242        let mut queue: VecDeque<u32> = VecDeque::with_capacity(n);
243
244        for i in 0..k {
245            let source_idx = sorted_indices[(i * n) / k];
246            brandes_bfs_inplace(
247                source_idx, &self.adj, &mut stack, &mut pred, &mut sigma, &mut dist, &mut queue,
248            );
249            brandes_accumulate_inplace(&stack, &pred, &sigma, &mut delta);
250            for &w in &stack {
251                if w != source_idx {
252                    betweenness[w as usize] += delta[w as usize] * scale;
253                }
254            }
255        }
256
257        if n > 2 {
258            let normalization = 1.0 / ((n - 1) * (n - 2)) as f64;
259            for v in betweenness.iter_mut() {
260                *v *= normalization;
261            }
262        }
263
264        self.ids
265            .iter()
266            .enumerate()
267            .map(|(i, id)| (id.clone(), betweenness[i]))
268            .collect()
269    }
270
271    /// Calculate betweenness centrality for all functions (exact).
272    ///
273    /// Uses Brandes' algorithm: O(N × (N+E)). For large graphs use
274    /// `betweenness_centrality_approx` instead.
275    ///
276    /// BFS working buffers are pre-allocated once and reused across all N iterations.
277    pub fn betweenness_centrality(&self) -> HashMap<String, f64> {
278        let n = self.ids.len();
279        let mut betweenness = vec![0.0f64; n];
280
281        if n == 0 {
282            return HashMap::new();
283        }
284
285        let mut stack: Vec<u32> = Vec::with_capacity(n);
286        let mut pred: Vec<Vec<u32>> = vec![Vec::new(); n];
287        let mut sigma = vec![0.0f64; n];
288        let mut dist = vec![-1i32; n];
289        let mut delta = vec![0.0f64; n];
290        let mut queue: VecDeque<u32> = VecDeque::with_capacity(n);
291
292        for source_idx in 0..n as u32 {
293            brandes_bfs_inplace(
294                source_idx, &self.adj, &mut stack, &mut pred, &mut sigma, &mut dist, &mut queue,
295            );
296            brandes_accumulate_inplace(&stack, &pred, &sigma, &mut delta);
297            for &w in &stack {
298                if w != source_idx {
299                    betweenness[w as usize] += delta[w as usize];
300                }
301            }
302        }
303
304        if n > 2 {
305            let normalization = 1.0 / ((n - 1) * (n - 2)) as f64;
306            for v in betweenness.iter_mut() {
307                *v *= normalization;
308            }
309        }
310
311        self.ids
312            .iter()
313            .enumerate()
314            .map(|(i, id)| (id.clone(), betweenness[i]))
315            .collect()
316    }
317
318    /// Find strongly connected components using iterative Tarjan's algorithm.
319    ///
320    /// Returns a map from function ID to (scc_id, scc_size).
321    /// Functions in the same SCC form a cyclic dependency group.
322    pub fn find_strongly_connected_components(&self) -> HashMap<String, (usize, usize)> {
323        let n = self.ids.len();
324
325        // Pre-sort adjacency lists by node ID string for determinism
326        let sorted_adj: Vec<Vec<u32>> = self
327            .adj
328            .iter()
329            .map(|v| {
330                let mut s = v.clone();
331                s.sort_by_key(|&i| &self.ids[i as usize]);
332                s
333            })
334            .collect();
335
336        let mut node_index: Vec<i64> = vec![-1; n]; // -1 = unvisited
337        let mut lowlink: Vec<u32> = vec![0; n];
338        let mut on_stack: Vec<bool> = vec![false; n];
339        let mut tarjan_stack: Vec<u32> = Vec::new();
340        let mut index_counter: u32 = 0;
341        let mut scc_id: usize = 0;
342        let mut node_scc: Vec<usize> = vec![0; n];
343        let mut scc_sizes: Vec<usize> = Vec::new();
344
345        // Process nodes in sorted order for determinism
346        let mut sorted_nodes: Vec<u32> = (0..n as u32).collect();
347        sorted_nodes.sort_by_key(|&i| &self.ids[i as usize]);
348
349        // Work stack: (node_idx, next_successor_position)
350        let mut work: Vec<(u32, usize)> = Vec::new();
351
352        for &start in &sorted_nodes {
353            if node_index[start as usize] >= 0 {
354                continue;
355            }
356
357            work.push((start, 0));
358            node_index[start as usize] = index_counter as i64;
359            lowlink[start as usize] = index_counter;
360            index_counter += 1;
361            tarjan_stack.push(start);
362            on_stack[start as usize] = true;
363
364            while !work.is_empty() {
365                let (v, si) = *work.last().unwrap();
366                let vi = v as usize;
367
368                if si < sorted_adj[vi].len() {
369                    let w = sorted_adj[vi][si];
370                    work.last_mut().unwrap().1 += 1;
371                    let wi = w as usize;
372
373                    if node_index[wi] < 0 {
374                        // Not yet visited: push and initialize
375                        work.push((w, 0));
376                        node_index[wi] = index_counter as i64;
377                        lowlink[wi] = index_counter;
378                        index_counter += 1;
379                        tarjan_stack.push(w);
380                        on_stack[wi] = true;
381                    } else if on_stack[wi] {
382                        lowlink[vi] = lowlink[vi].min(node_index[wi] as u32);
383                    }
384                } else {
385                    work.pop();
386                    // Update parent's lowlink
387                    if let Some(&(parent, _)) = work.last() {
388                        lowlink[parent as usize] = lowlink[parent as usize].min(lowlink[vi]);
389                    }
390                    // If v is SCC root, pop the SCC
391                    if lowlink[vi] == node_index[vi] as u32 {
392                        let mut size = 0;
393                        loop {
394                            let w = tarjan_stack.pop().unwrap();
395                            on_stack[w as usize] = false;
396                            node_scc[w as usize] = scc_id;
397                            size += 1;
398                            if w == v {
399                                break;
400                            }
401                        }
402                        scc_sizes.push(size);
403                        scc_id += 1;
404                    }
405                }
406            }
407        }
408
409        self.ids
410            .iter()
411            .enumerate()
412            .map(|(i, id)| {
413                let sid = node_scc[i];
414                let size = scc_sizes.get(sid).copied().unwrap_or(1);
415                (id.clone(), (sid, size))
416            })
417            .collect()
418    }
419
420    /// Compute dependency depth for all functions.
421    ///
422    /// Returns a map from function ID to depth (0 = entry point, None = unreachable).
423    pub fn compute_dependency_depth(&self) -> HashMap<String, Option<usize>> {
424        let n = self.ids.len();
425        let mut depths: Vec<Option<usize>> = vec![None; n];
426        let mut queue: VecDeque<(u32, usize)> = VecDeque::new();
427
428        // Identify entry points
429        let mut entry_indices: Vec<u32> = (0..n as u32)
430            .filter(|&i| self.is_entry_point(&self.ids[i as usize]))
431            .collect();
432
433        if entry_indices.is_empty() {
434            let mut fan_in = vec![0usize; n];
435            for callees in &self.adj {
436                for &c in callees {
437                    fan_in[c as usize] += 1;
438                }
439            }
440            entry_indices = (0..n as u32).filter(|&i| fan_in[i as usize] == 0).collect();
441        }
442
443        for entry in entry_indices {
444            depths[entry as usize] = Some(0);
445            queue.push_back((entry, 0));
446        }
447
448        while let Some((node_idx, depth)) = queue.pop_front() {
449            for &callee_idx in &self.adj[node_idx as usize] {
450                let ci = callee_idx as usize;
451                let current = depths[ci];
452                if current.is_none() || current.unwrap() > depth + 1 {
453                    depths[ci] = Some(depth + 1);
454                    queue.push_back((callee_idx, depth + 1));
455                }
456            }
457        }
458
459        self.ids
460            .iter()
461            .enumerate()
462            .map(|(i, id)| (id.clone(), depths[i]))
463            .collect()
464    }
465
466    /// Build a map from function ID to its fan-in count in O(N + E).
467    pub fn build_fan_in_map(&self) -> HashMap<String, usize> {
468        let n = self.ids.len();
469        let mut counts = vec![0usize; n];
470        for callees in &self.adj {
471            for &callee in callees {
472                counts[callee as usize] += 1;
473            }
474        }
475        self.ids
476            .iter()
477            .enumerate()
478            .map(|(i, id)| (id.clone(), counts[i]))
479            .collect()
480    }
481
482    /// Check if a function is likely an entry point.
483    pub fn is_entry_point(&self, function_id: &str) -> bool {
484        let function_name = function_id.split("::").last().unwrap_or("").to_lowercase();
485
486        let entry_point_names = [
487            "main",
488            "start",
489            "init",
490            "initialize",
491            "run",
492            "execute",
493            "bootstrap",
494        ];
495
496        let handler_patterns = [
497            "handle",
498            "handler",
499            "onrequest",
500            "onmessage",
501            "onevent",
502            "middleware",
503            "controller",
504        ];
505
506        if entry_point_names.contains(&function_name.as_str()) {
507            return true;
508        }
509
510        for pattern in &handler_patterns {
511            if function_name.contains(pattern) {
512                return true;
513            }
514        }
515
516        false
517    }
518
519    /// Calculate all graph metrics for a function.
520    pub fn metrics_for(
521        &self,
522        function_id: &str,
523        pagerank_scores: &HashMap<String, f64>,
524        betweenness_scores: &HashMap<String, f64>,
525    ) -> GraphMetrics {
526        GraphMetrics {
527            fan_in: self.fan_in(function_id),
528            fan_out: self.fan_out(function_id),
529            pagerank: pagerank_scores.get(function_id).copied().unwrap_or(0.0),
530            betweenness: betweenness_scores.get(function_id).copied().unwrap_or(0.0),
531        }
532    }
533}
534
535/// Brandes' BFS phase from a single source, operating on pre-allocated Vec buffers.
536///
537/// `stack` enters holding the previous call's visited nodes (used for cleanup) and exits
538/// holding the current BFS order. This avoids a separate `touched` tracker while keeping
539/// cleanup O(visited) rather than O(N).
540fn brandes_bfs_inplace(
541    source: u32,
542    adj: &[Vec<u32>],
543    stack: &mut Vec<u32>,
544    pred: &mut [Vec<u32>],
545    sigma: &mut [f64],
546    dist: &mut [i32],
547    queue: &mut VecDeque<u32>,
548) {
549    // Clear state for nodes visited in the previous call (stack still holds them)
550    for &i in stack.iter() {
551        pred[i as usize].clear();
552        sigma[i as usize] = 0.0;
553        dist[i as usize] = -1;
554    }
555    stack.clear();
556    queue.clear();
557
558    let s = source as usize;
559    dist[s] = 0;
560    sigma[s] = 1.0;
561    queue.push_back(source);
562
563    while let Some(v) = queue.pop_front() {
564        let vi = v as usize;
565        stack.push(v);
566        for &w in &adj[vi] {
567            let wi = w as usize;
568            if dist[wi] < 0 {
569                queue.push_back(w);
570                dist[wi] = dist[vi] + 1;
571            }
572            if dist[wi] == dist[vi] + 1 {
573                sigma[wi] += sigma[vi];
574                pred[wi].push(v);
575            }
576        }
577    }
578}
579
580/// Brandes' accumulation phase. Resets and fills `delta` for nodes on `stack`.
581fn brandes_accumulate_inplace(stack: &[u32], pred: &[Vec<u32>], sigma: &[f64], delta: &mut [f64]) {
582    for &w in stack {
583        delta[w as usize] = 0.0;
584    }
585    for &w in stack.iter().rev() {
586        let wi = w as usize;
587        for &v in &pred[wi] {
588            let vi = v as usize;
589            delta[vi] += (sigma[vi] / sigma[wi].max(1e-300)) * (1.0 + delta[wi]);
590        }
591    }
592}
593
594impl Default for CallGraph {
595    fn default() -> Self {
596        Self::new()
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn test_empty_graph() {
606        let graph = CallGraph::new();
607        assert_eq!(graph.node_count(), 0);
608        assert_eq!(graph.edge_count(), 0);
609    }
610
611    #[test]
612    fn test_fan_in_fan_out() {
613        let mut graph = CallGraph::new();
614
615        // A -> B
616        // A -> C
617        // B -> C
618        graph.add_edge("A".to_string(), "B".to_string());
619        graph.add_edge("A".to_string(), "C".to_string());
620        graph.add_edge("B".to_string(), "C".to_string());
621
622        assert_eq!(graph.fan_in("A"), 0); // No one calls A
623        assert_eq!(graph.fan_out("A"), 2); // A calls B and C
624
625        assert_eq!(graph.fan_in("B"), 1); // A calls B
626        assert_eq!(graph.fan_out("B"), 1); // B calls C
627
628        assert_eq!(graph.fan_in("C"), 2); // A and B call C
629        assert_eq!(graph.fan_out("C"), 0); // C calls nothing
630    }
631
632    #[test]
633    fn test_pagerank() {
634        let mut graph = CallGraph::new();
635
636        // Simple chain: A -> B -> C
637        graph.add_edge("A".to_string(), "B".to_string());
638        graph.add_edge("B".to_string(), "C".to_string());
639
640        let ranks = graph.pagerank(0.85, 20, 1e-6);
641
642        // C should have highest rank (called by B, which is called by A)
643        // A should have lowest rank (not called by anyone)
644        assert!(ranks.get("C").copied().unwrap_or(0.0) > ranks.get("B").copied().unwrap_or(0.0));
645        assert!(ranks.get("B").copied().unwrap_or(0.0) > ranks.get("A").copied().unwrap_or(0.0));
646    }
647
648    #[test]
649    fn test_build_fan_in_map() {
650        let mut graph = CallGraph::new();
651        // A -> B, A -> C, B -> C
652        graph.add_edge("A".to_string(), "B".to_string());
653        graph.add_edge("A".to_string(), "C".to_string());
654        graph.add_edge("B".to_string(), "C".to_string());
655
656        let fan_in = graph.build_fan_in_map();
657        assert_eq!(fan_in.get("A").copied().unwrap_or(0), 0); // nobody calls A
658        assert_eq!(fan_in.get("B").copied().unwrap_or(0), 1); // A calls B
659        assert_eq!(fan_in.get("C").copied().unwrap_or(0), 2); // A and B call C
660    }
661
662    #[test]
663    fn test_betweenness_linear_chain() {
664        // a -> b -> c: b is the only intermediary on the a→c shortest path.
665        // Normalized betweenness for b = 1 / ((3-1)(3-2)) = 0.5
666        let mut graph = CallGraph::new();
667        graph.add_edge("a".to_string(), "b".to_string());
668        graph.add_edge("b".to_string(), "c".to_string());
669
670        let scores = graph.betweenness_centrality();
671        assert!(
672            (scores["b"] - 0.5).abs() < 1e-10,
673            "b betweenness should be 0.5"
674        );
675        assert!(scores["a"].abs() < 1e-10, "a betweenness should be 0.0");
676        assert!(scores["c"].abs() < 1e-10, "c betweenness should be 0.0");
677    }
678
679    #[test]
680    fn test_approx_betweenness_equals_exact_when_k_geq_n() {
681        // When k >= n the n<=k guard forces exact computation; approx and exact must match.
682        let mut graph = CallGraph::new();
683        graph.add_edge("a".to_string(), "b".to_string());
684        graph.add_edge("b".to_string(), "c".to_string());
685        graph.add_edge("c".to_string(), "d".to_string());
686
687        let exact = graph.betweenness_centrality();
688        let approx = graph.betweenness_centrality_approx(100); // k >> n=4
689
690        for (node, &exact_val) in &exact {
691            let approx_val = approx.get(node).copied().unwrap_or(0.0);
692            assert!(
693                (exact_val - approx_val).abs() < 1e-10,
694                "node {node}: exact={exact_val}, approx={approx_val}"
695            );
696        }
697    }
698
699    #[test]
700    fn test_approx_betweenness_identifies_bridge() {
701        // "bridge" is the only node connecting callers to callees, so it must have
702        // the highest betweenness even when approximation is used (k=2 < n=4).
703        //
704        //   a ──► bridge ──► y
705        //                └──► z
706        let mut graph = CallGraph::new();
707        graph.add_edge("a".to_string(), "bridge".to_string());
708        graph.add_edge("bridge".to_string(), "y".to_string());
709        graph.add_edge("bridge".to_string(), "z".to_string());
710
711        let approx = graph.betweenness_centrality_approx(2);
712        let bridge_score = approx.get("bridge").copied().unwrap_or(0.0);
713        for (node, &score) in &approx {
714            if node != "bridge" {
715                assert!(
716                    bridge_score >= score,
717                    "bridge ({bridge_score}) should dominate {node} ({score})"
718                );
719            }
720        }
721    }
722
723    #[test]
724    fn test_approx_betweenness_pivot_covers_tail() {
725        // Regression for the (i*n)/k sampling fix.
726        //
727        // "z_source" sorts last and has outgoing paths through "hub" to several
728        // destinations.  With the old `step = n/k` formula, z_source would be
729        // excluded as a pivot when k is small (tail nodes are never sampled).
730        // We verify:
731        //   1. approx(k=n) matches exact exactly (the n<=k fallback).
732        //   2. hub has the highest betweenness in the exact result — confirming
733        //      the graph structure is meaningful.
734        //
735        //   a_in ──┐
736        //   b_in ──┤──► hub ──► x_out
737        //  z_source┘        └──► y_out
738        let mut graph = CallGraph::new();
739        graph.add_edge("a_in".to_string(), "hub".to_string());
740        graph.add_edge("b_in".to_string(), "hub".to_string());
741        graph.add_edge("z_source".to_string(), "hub".to_string());
742        graph.add_edge("hub".to_string(), "x_out".to_string());
743        graph.add_edge("hub".to_string(), "y_out".to_string());
744
745        let n = graph.node_count();
746        let exact = graph.betweenness_centrality();
747
748        // k=n must be byte-for-byte identical to exact
749        let approx_full = graph.betweenness_centrality_approx(n);
750        for (node, &val) in &exact {
751            let av = approx_full.get(node).copied().unwrap_or(0.0);
752            assert!(
753                (val - av).abs() < 1e-10,
754                "k=n mismatch for {node}: exact={val}, approx={av}"
755            );
756        }
757
758        // hub must be the top-betweenness node in exact
759        let hub_score = exact.get("hub").copied().unwrap_or(0.0);
760        assert!(hub_score > 0.0, "hub should have non-zero betweenness");
761        for (node, &val) in &exact {
762            if node != "hub" {
763                assert!(
764                    hub_score >= val,
765                    "hub ({hub_score}) should have highest betweenness, but {node}={val}"
766                );
767            }
768        }
769    }
770
771    #[test]
772    fn test_approx_betweenness_top_hubs_rank_preserved() {
773        // Core invariant: approximate betweenness surfaces the biggest structural
774        // offenders in the right order, even at k << n.
775        //
776        // Three dumbbell clusters, each with a single hub bridging its in- and
777        // out-nodes. Different cluster sizes give separated exact betweenness so
778        // we can assert strict rank ordering, not just set membership.
779        //
780        //   in_hub_a_0..49 ──► hub_a ──► out_hub_a_0..49   (50×50 = 2500 paths)
781        //   in_hub_b_0..29 ──► hub_b ──► out_hub_b_0..29   (30×30 =  900 paths)
782        //   in_hub_c_0..14 ──► hub_c ──► out_hub_c_0..14   (15×15 =  225 paths)
783        //
784        // ~193 nodes total, k=32. The three hubs are the only non-leaf nodes so
785        // they must be the top-3 in both exact and approximate rankings.
786        let mut graph = CallGraph::new();
787        for (hub, size) in [("hub_a", 50usize), ("hub_b", 30), ("hub_c", 15)] {
788            for i in 0..size {
789                graph.add_edge(format!("in_{hub}_{i}"), hub.to_string());
790                graph.add_edge(hub.to_string(), format!("out_{hub}_{i}"));
791            }
792        }
793
794        assert!(
795            graph.node_count() > 32,
796            "graph must be large enough that k=32 is a real approximation"
797        );
798
799        let exact = graph.betweenness_centrality();
800        let approx = graph.betweenness_centrality_approx(32);
801
802        // Exact ranking must be hub_a > hub_b > hub_c (structural guarantee from cluster sizes)
803        let ex_a = exact.get("hub_a").copied().unwrap_or(0.0);
804        let ex_b = exact.get("hub_b").copied().unwrap_or(0.0);
805        let ex_c = exact.get("hub_c").copied().unwrap_or(0.0);
806        assert!(
807            ex_a > ex_b && ex_b > ex_c,
808            "exact: hub_a={ex_a} hub_b={ex_b} hub_c={ex_c}"
809        );
810
811        // Approximate ranking must preserve hub_a > hub_b > hub_c
812        let ap_a = approx.get("hub_a").copied().unwrap_or(0.0);
813        let ap_b = approx.get("hub_b").copied().unwrap_or(0.0);
814        let ap_c = approx.get("hub_c").copied().unwrap_or(0.0);
815        assert!(
816            ap_a > ap_b && ap_b > ap_c,
817            "approx rank broken: hub_a={ap_a} hub_b={ap_b} hub_c={ap_c}"
818        );
819
820        // All three hubs must appear in the top-3 — no leaf node should outrank them
821        let mut ranked: Vec<(&str, f64)> = approx.iter().map(|(k, &v)| (k.as_str(), v)).collect();
822        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
823        let top3: Vec<&str> = ranked.iter().take(3).map(|(name, _)| *name).collect();
824        assert!(
825            top3.contains(&"hub_a"),
826            "hub_a missing from top-3: {top3:?}"
827        );
828        assert!(
829            top3.contains(&"hub_b"),
830            "hub_b missing from top-3: {top3:?}"
831        );
832        assert!(
833            top3.contains(&"hub_c"),
834            "hub_c missing from top-3: {top3:?}"
835        );
836    }
837}