Skip to main content

the_code_graph_domain/analysis/
flow.rs

1use crate::model::*;
2use std::collections::{HashMap, HashSet, VecDeque};
3
4// ---------------------------------------------------------------------------
5// High-confidence edge filter
6// ---------------------------------------------------------------------------
7
8fn is_high_confidence(kind: &EdgeKind) -> bool {
9    matches!(
10        kind,
11        EdgeKind::Calls | EdgeKind::Extends | EdgeKind::Implements | EdgeKind::Embeds
12    )
13}
14
15// ---------------------------------------------------------------------------
16// Entry point detection
17// ---------------------------------------------------------------------------
18
19/// Callable SymbolKinds eligible for PublicRoot classification.
20const CALLABLE_KINDS: &[SymbolKind] = &[
21    SymbolKind::Function,
22    SymbolKind::Method,
23    SymbolKind::Class,
24    SymbolKind::Struct,
25];
26
27/// HTTP-handler decorator substrings (case-insensitive match).
28const HTTP_DECORATORS: &[&str] = &[
29    "get",
30    "post",
31    "put",
32    "delete",
33    "patch",
34    "app.route",
35    "router.get",
36    "router.post",
37    "api_view",
38    "route",
39];
40
41/// CLI-command decorator substrings (case-insensitive match).
42const CLI_DECORATORS: &[&str] = &["command", "subcommand", "clap", "parser"];
43
44/// Detect entry points from symbols and edges.
45pub fn detect_entry_points(
46    symbols: &[SymbolNode],
47    edges: &[Edge],
48    config: &FlowConfig,
49) -> Vec<EntryPoint> {
50    let excluded: HashSet<&str> = config
51        .excluded_entry_points
52        .iter()
53        .map(|s| s.as_str())
54        .collect();
55    let extra: HashSet<&str> = config
56        .extra_entry_points
57        .iter()
58        .map(|s| s.as_str())
59        .collect();
60
61    // Build set of symbols that have incoming Calls edges (not PublicRoot candidates)
62    let has_incoming_calls: HashSet<&str> = edges
63        .iter()
64        .filter(|e| e.kind == EdgeKind::Calls)
65        .map(|e| e.target.as_str())
66        .collect();
67
68    // Count outgoing edges per symbol (for PublicRoot ranking)
69    let mut outgoing_count: HashMap<&str, usize> = HashMap::new();
70    for edge in edges {
71        if is_high_confidence(&edge.kind) {
72            *outgoing_count.entry(edge.source.as_str()).or_default() += 1;
73        }
74    }
75
76    let mut results: Vec<EntryPoint> = Vec::new();
77    let mut public_roots: Vec<EntryPoint> = Vec::new();
78
79    for sym in symbols {
80        let qn = sym.qualified_name.as_str();
81
82        // Skip excluded
83        if excluded.contains(qn) {
84            continue;
85        }
86
87        // Classify by priority: Main > Test > HttpHandler > CliCommand > PublicRoot
88        if let Some(ep) = classify_symbol(sym, &has_incoming_calls, &extra) {
89            if ep.kind == EntryPointKind::PublicRoot {
90                let mut ep_with_score = ep;
91                // Store outgoing count in confidence temporarily for sorting
92                ep_with_score.confidence = 0.7;
93                public_roots.push(ep_with_score);
94            } else {
95                results.push(ep);
96            }
97        }
98    }
99
100    // Cap PublicRoot at max_public_roots, sorted by outgoing edge count descending
101    public_roots.sort_by(|a, b| {
102        let a_out = outgoing_count
103            .get(a.qualified_name.as_str())
104            .copied()
105            .unwrap_or(0);
106        let b_out = outgoing_count
107            .get(b.qualified_name.as_str())
108            .copied()
109            .unwrap_or(0);
110        b_out.cmp(&a_out)
111    });
112    public_roots.truncate(config.max_public_roots);
113    results.extend(public_roots);
114
115    results
116}
117
118fn classify_symbol(
119    sym: &SymbolNode,
120    has_incoming_calls: &HashSet<&str>,
121    extra: &HashSet<&str>,
122) -> Option<EntryPoint> {
123    let qn = &sym.qualified_name;
124    let name_lower = sym.name.to_lowercase();
125    let is_extra = extra.contains(qn.as_str());
126
127    // Main detection
128    if name_lower == "main" {
129        return Some(EntryPoint {
130            qualified_name: qn.clone(),
131            kind: EntryPointKind::Main,
132            confidence: 1.0,
133        });
134    }
135    if sym.decorators.iter().any(|d| d.contains("tokio::main")) {
136        return Some(EntryPoint {
137            qualified_name: qn.clone(),
138            kind: EntryPointKind::Main,
139            confidence: 1.0,
140        });
141    }
142
143    // Test detection
144    if sym.is_test || sym.kind == SymbolKind::Test {
145        return Some(EntryPoint {
146            qualified_name: qn.clone(),
147            kind: EntryPointKind::Test,
148            confidence: 1.0,
149        });
150    }
151    if name_lower.starts_with("test_") {
152        return Some(EntryPoint {
153            qualified_name: qn.clone(),
154            kind: EntryPointKind::Test,
155            confidence: 0.9,
156        });
157    }
158
159    // HTTP handler detection
160    for decorator in &sym.decorators {
161        let dec_lower = decorator.to_lowercase();
162        if HTTP_DECORATORS.iter().any(|h| dec_lower.contains(h)) {
163            return Some(EntryPoint {
164                qualified_name: qn.clone(),
165                kind: EntryPointKind::HttpHandler,
166                confidence: 1.0,
167            });
168        }
169    }
170
171    // CLI command detection
172    for decorator in &sym.decorators {
173        let dec_lower = decorator.to_lowercase();
174        if CLI_DECORATORS.iter().any(|c| dec_lower.contains(c)) {
175            return Some(EntryPoint {
176                qualified_name: qn.clone(),
177                kind: EntryPointKind::CliCommand,
178                confidence: 1.0,
179            });
180        }
181    }
182
183    // Extra entry points (forced, regardless of incoming calls)
184    if is_extra {
185        return Some(EntryPoint {
186            qualified_name: qn.clone(),
187            kind: EntryPointKind::PublicRoot,
188            confidence: 0.8,
189        });
190    }
191
192    // PublicRoot: exported public symbol with zero incoming Calls, callable kind only
193    if sym.is_exported
194        && sym.visibility == Visibility::Public
195        && CALLABLE_KINDS.contains(&sym.kind)
196        && !has_incoming_calls.contains(qn.as_str())
197    {
198        return Some(EntryPoint {
199            qualified_name: qn.clone(),
200            kind: EntryPointKind::PublicRoot,
201            confidence: 0.7,
202        });
203    }
204
205    None
206}
207
208// ---------------------------------------------------------------------------
209// Betweenness centrality (Brandes' algorithm)
210// ---------------------------------------------------------------------------
211
212/// Compute betweenness centrality for all nodes using Brandes' algorithm.
213/// Only traverses High-confidence edges (Calls, Extends, Implements, Embeds).
214/// Returns normalized scores in [0.0, 1.0] using directed graph factor (n-1)(n-2).
215pub fn brandes_betweenness(nodes: &HashSet<String>, edges: &[Edge]) -> HashMap<String, f64> {
216    let n = nodes.len();
217    if n < 2 {
218        return nodes.iter().map(|node| (node.clone(), 0.0)).collect();
219    }
220
221    // Build adjacency list from High-confidence edges only
222    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
223    for node in nodes {
224        adj.entry(node.as_str()).or_default();
225    }
226    for edge in edges {
227        if is_high_confidence(&edge.kind)
228            && nodes.contains(&edge.source)
229            && nodes.contains(&edge.target)
230        {
231            adj.entry(edge.source.as_str())
232                .or_default()
233                .push(edge.target.as_str());
234        }
235    }
236
237    let mut centrality: HashMap<String, f64> = nodes.iter().map(|n| (n.clone(), 0.0)).collect();
238
239    // Brandes' algorithm: BFS from each source
240    for source in nodes {
241        let mut stack: Vec<&str> = Vec::new();
242        let mut predecessors: HashMap<&str, Vec<&str>> = HashMap::new();
243        let mut sigma: HashMap<&str, f64> = HashMap::new();
244        let mut dist: HashMap<&str, i64> = HashMap::new();
245
246        for node in nodes {
247            sigma.insert(node.as_str(), 0.0);
248            dist.insert(node.as_str(), -1);
249        }
250        sigma.insert(source.as_str(), 1.0);
251        dist.insert(source.as_str(), 0);
252
253        let mut queue: VecDeque<&str> = VecDeque::new();
254        queue.push_back(source.as_str());
255
256        // BFS
257        while let Some(v) = queue.pop_front() {
258            stack.push(v);
259            let d_v = dist[v];
260            if let Some(neighbors) = adj.get(v) {
261                for &w in neighbors {
262                    // First visit?
263                    if dist[w] < 0 {
264                        dist.insert(w, d_v + 1);
265                        queue.push_back(w);
266                    }
267                    // Shortest path via v?
268                    if dist[w] == d_v + 1 {
269                        *sigma.get_mut(w).unwrap() += sigma[v];
270                        predecessors.entry(w).or_default().push(v);
271                    }
272                }
273            }
274        }
275
276        // Back-propagation
277        let mut delta: HashMap<&str, f64> = nodes.iter().map(|n| (n.as_str(), 0.0)).collect();
278        while let Some(w) = stack.pop() {
279            if let Some(preds) = predecessors.get(w) {
280                for &v in preds {
281                    let d = (sigma[v] / sigma[w]) * (1.0 + delta[w]);
282                    *delta.get_mut(v).unwrap() += d;
283                }
284            }
285            if w != source.as_str() {
286                *centrality.get_mut(w).unwrap() += delta[w];
287            }
288        }
289    }
290
291    // Normalize by (n-1)(n-2) — directed graph factor
292    let norm = (n as f64 - 1.0) * (n as f64 - 2.0);
293    if norm > 0.0 {
294        for val in centrality.values_mut() {
295            *val /= norm;
296        }
297    }
298
299    centrality
300}
301
302// ---------------------------------------------------------------------------
303// Flow enumeration (bounded DFS)
304// ---------------------------------------------------------------------------
305
306/// Enumerate execution flows from entry points via bounded DFS.
307/// Only traverses High-confidence edges. Respects depth limit, global flow cap,
308/// and per-entry-point visit budget. Uses per-path cycle detection.
309pub fn enumerate_flows(
310    entry_points: &[EntryPoint],
311    edges: &[Edge],
312    config: &FlowConfig,
313) -> Vec<ExecutionFlow> {
314    // Build adjacency list from High-confidence edges only
315    let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
316    for edge in edges {
317        if is_high_confidence(&edge.kind) {
318            adj.entry(edge.source.as_str())
319                .or_default()
320                .push(edge.target.as_str());
321        }
322    }
323
324    let mut all_flows: Vec<ExecutionFlow> = Vec::new();
325
326    for ep in entry_points {
327        if all_flows.len() >= config.max_flows {
328            break;
329        }
330
331        let mut ep_flows: Vec<ExecutionFlow> = Vec::new();
332        let mut visit_count: usize = 0;
333        let mut truncated = false;
334
335        // DFS with per-path visited set (iterative with explicit stack)
336        // Stack holds: (current_node, path_so_far, visited_set)
337        let mut stack: Vec<(String, Vec<String>, HashSet<String>)> = Vec::new();
338        let initial_visited: HashSet<String> = [ep.qualified_name.clone()].into_iter().collect();
339        stack.push((
340            ep.qualified_name.clone(),
341            vec![ep.qualified_name.clone()],
342            initial_visited,
343        ));
344
345        while let Some((node, path, visited)) = stack.pop() {
346            visit_count += 1;
347            if visit_count > config.visit_budget {
348                truncated = true;
349                break;
350            }
351            if all_flows.len() + ep_flows.len() >= config.max_flows {
352                break;
353            }
354
355            let neighbors: Vec<&str> = adj
356                .get(node.as_str())
357                .map(|v| v.as_slice())
358                .unwrap_or(&[])
359                .iter()
360                .filter(|&&n| !visited.contains(n))
361                .copied()
362                .collect();
363
364            if neighbors.is_empty() || path.len() >= config.max_depth {
365                // Terminal: no unvisited neighbors or depth limit reached
366                ep_flows.push(ExecutionFlow {
367                    entry: ep.qualified_name.clone(),
368                    path: path.clone(),
369                    depth: path.len(),
370                    truncated: false,
371                });
372            } else {
373                for &neighbor in &neighbors {
374                    let mut new_path = path.clone();
375                    new_path.push(neighbor.to_string());
376                    let mut new_visited = visited.clone();
377                    new_visited.insert(neighbor.to_string());
378                    stack.push((neighbor.to_string(), new_path, new_visited));
379                }
380            }
381        }
382
383        // Mark truncation on all flows from this entry point if budget was exhausted
384        if truncated {
385            for flow in &mut ep_flows {
386                flow.truncated = true;
387            }
388        }
389
390        let remaining = config.max_flows - all_flows.len();
391        ep_flows.truncate(remaining);
392        all_flows.extend(ep_flows);
393    }
394
395    all_flows
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    // -----------------------------------------------------------------------
407    // Helpers
408    // -----------------------------------------------------------------------
409
410    fn make_symbol(name: &str, qn: &str, kind: SymbolKind) -> SymbolNode {
411        SymbolNode {
412            name: name.into(),
413            qualified_name: qn.into(),
414            kind,
415            location: Location {
416                file: "src/lib.rs".into(),
417                line_start: 1,
418                line_end: 10,
419                col_start: 0,
420                col_end: 0,
421            },
422            visibility: Visibility::Public,
423            is_exported: true,
424            is_async: false,
425            is_test: false,
426            decorators: vec![],
427            signature: None,
428        }
429    }
430
431    fn make_edge(kind: EdgeKind, source: &str, target: &str) -> Edge {
432        Edge {
433            kind,
434            source: source.into(),
435            target: target.into(),
436            metadata: None,
437        }
438    }
439
440    // -----------------------------------------------------------------------
441    // T02: Entry point detection tests
442    // -----------------------------------------------------------------------
443
444    #[test]
445    fn detect_main_entry_point() {
446        let sym = make_symbol("main", "src/main.rs::main", SymbolKind::Function);
447        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
448        assert_eq!(entries.len(), 1);
449        assert_eq!(entries[0].kind, EntryPointKind::Main);
450        assert_eq!(entries[0].confidence, 1.0);
451    }
452
453    #[test]
454    fn detect_tokio_main() {
455        let mut sym = make_symbol("main", "src/main.rs::main", SymbolKind::Function);
456        sym.decorators = vec!["tokio::main".into()];
457        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
458        assert!(entries.iter().any(|e| e.kind == EntryPointKind::Main));
459    }
460
461    #[test]
462    fn detect_test_entry_point() {
463        let mut sym = make_symbol("test_foo", "src/lib.rs::test_foo", SymbolKind::Function);
464        sym.is_test = true;
465        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
466        assert_eq!(entries.len(), 1);
467        assert_eq!(entries[0].kind, EntryPointKind::Test);
468    }
469
470    #[test]
471    fn detect_http_handler() {
472        let mut sym = make_symbol("handle", "src/api.rs::handle", SymbolKind::Function);
473        sym.decorators = vec!["Get".into()];
474        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
475        assert_eq!(entries.len(), 1);
476        assert_eq!(entries[0].kind, EntryPointKind::HttpHandler);
477    }
478
479    #[test]
480    fn detect_cli_command() {
481        let mut sym = make_symbol("run_cmd", "src/cli.rs::run_cmd", SymbolKind::Function);
482        sym.decorators = vec!["command".into()];
483        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
484        assert_eq!(entries.len(), 1);
485        assert_eq!(entries[0].kind, EntryPointKind::CliCommand);
486    }
487
488    #[test]
489    fn detect_public_root() {
490        let sym = make_symbol("init", "src/lib.rs::init", SymbolKind::Function);
491        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
492        assert!(entries.iter().any(|e| e.kind == EntryPointKind::PublicRoot));
493    }
494
495    #[test]
496    fn public_root_excluded_when_has_incoming_calls() {
497        let sym = make_symbol("helper", "src/lib.rs::helper", SymbolKind::Function);
498        let edge = make_edge(EdgeKind::Calls, "src/main.rs::main", "src/lib.rs::helper");
499        let entries = detect_entry_points(&[sym], &[edge], &FlowConfig::default());
500        assert!(!entries.iter().any(|e| e.kind == EntryPointKind::PublicRoot));
501    }
502
503    #[test]
504    fn public_root_capped_at_max() {
505        let config = FlowConfig {
506            max_public_roots: 2,
507            ..FlowConfig::default()
508        };
509        let syms: Vec<_> = (0..10)
510            .map(|i| {
511                make_symbol(
512                    &format!("fn{i}"),
513                    &format!("src/lib.rs::fn{i}"),
514                    SymbolKind::Function,
515                )
516            })
517            .collect();
518        let entries = detect_entry_points(&syms, &[], &config);
519        let public_roots: Vec<_> = entries
520            .iter()
521            .filter(|e| e.kind == EntryPointKind::PublicRoot)
522            .collect();
523        assert!(public_roots.len() <= 2);
524    }
525
526    #[test]
527    fn excluded_entry_points_filtered() {
528        let sym = make_symbol("main", "src/main.rs::main", SymbolKind::Function);
529        let config = FlowConfig {
530            excluded_entry_points: vec!["src/main.rs::main".into()],
531            ..FlowConfig::default()
532        };
533        let entries = detect_entry_points(&[sym], &[], &config);
534        assert!(entries.is_empty());
535    }
536
537    #[test]
538    fn extra_entry_points_added() {
539        let sym = make_symbol("custom", "src/lib.rs::custom", SymbolKind::Function);
540        let edge = make_edge(EdgeKind::Calls, "src/main.rs::main", "src/lib.rs::custom");
541        let config = FlowConfig {
542            extra_entry_points: vec!["src/lib.rs::custom".into()],
543            ..FlowConfig::default()
544        };
545        let entries = detect_entry_points(&[sym], &[edge], &config);
546        assert!(!entries.is_empty());
547    }
548
549    #[test]
550    fn detect_python_dunder_main() {
551        let sym = make_symbol("main", "app.py::main", SymbolKind::Function);
552        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
553        assert!(entries.iter().any(|e| e.kind == EntryPointKind::Main));
554    }
555
556    #[test]
557    fn detect_test_prefix_python() {
558        let mut sym = make_symbol(
559            "test_login",
560            "test_auth.py::test_login",
561            SymbolKind::Function,
562        );
563        sym.is_test = false;
564        let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
565        assert!(entries.iter().any(|e| e.kind == EntryPointKind::Test));
566    }
567
568    #[test]
569    fn public_root_excludes_non_callable_kinds() {
570        for kind in [
571            SymbolKind::Const,
572            SymbolKind::Enum,
573            SymbolKind::TypeAlias,
574            SymbolKind::Variable,
575            SymbolKind::Property,
576            SymbolKind::Interface,
577            SymbolKind::Trait,
578        ] {
579            let sym = make_symbol("MyType", "src/lib.rs::MyType", kind);
580            let entries = detect_entry_points(&[sym], &[], &FlowConfig::default());
581            assert!(
582                !entries.iter().any(|e| e.kind == EntryPointKind::PublicRoot),
583                "{kind:?} should not be classified as PublicRoot"
584            );
585        }
586    }
587
588    // -----------------------------------------------------------------------
589    // T03: Brandes' betweenness centrality tests
590    // -----------------------------------------------------------------------
591
592    #[test]
593    fn brandes_linear_graph_center_has_highest_centrality() {
594        let edges = vec![
595            make_edge(EdgeKind::Calls, "a", "b"),
596            make_edge(EdgeKind::Calls, "b", "c"),
597            make_edge(EdgeKind::Calls, "c", "d"),
598            make_edge(EdgeKind::Calls, "d", "e"),
599        ];
600        let nodes: HashSet<String> = ["a", "b", "c", "d", "e"]
601            .iter()
602            .map(|s| s.to_string())
603            .collect();
604        let scores = brandes_betweenness(&nodes, &edges);
605        let c_score = scores.get("c").copied().unwrap_or(0.0);
606        let a_score = scores.get("a").copied().unwrap_or(0.0);
607        let e_score = scores.get("e").copied().unwrap_or(0.0);
608        assert!(
609            c_score > a_score,
610            "center should have higher betweenness than endpoints"
611        );
612        assert!(c_score > e_score);
613        for (_, &v) in &scores {
614            assert!(
615                v >= 0.0 && v <= 1.0,
616                "betweenness must be normalized to [0,1]"
617            );
618        }
619    }
620
621    #[test]
622    fn brandes_disconnected_nodes_have_zero_betweenness() {
623        let nodes: HashSet<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
624        let scores = brandes_betweenness(&nodes, &[]);
625        assert_eq!(*scores.get("a").unwrap_or(&0.0), 0.0);
626        assert_eq!(*scores.get("b").unwrap_or(&0.0), 0.0);
627    }
628
629    #[test]
630    fn brandes_diamond_graph_intermediaries_have_betweenness() {
631        let edges = vec![
632            make_edge(EdgeKind::Calls, "a", "b"),
633            make_edge(EdgeKind::Calls, "a", "c"),
634            make_edge(EdgeKind::Calls, "b", "d"),
635            make_edge(EdgeKind::Calls, "c", "d"),
636        ];
637        let nodes: HashSet<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
638        let scores = brandes_betweenness(&nodes, &edges);
639        let b = scores.get("b").copied().unwrap_or(0.0);
640        let c = scores.get("c").copied().unwrap_or(0.0);
641        assert!(b > 0.0, "intermediary b must have nonzero betweenness");
642        assert!(c > 0.0, "intermediary c must have nonzero betweenness");
643        assert!(
644            (b - c).abs() < 1e-9,
645            "symmetric intermediaries should have equal betweenness"
646        );
647    }
648
649    #[test]
650    fn brandes_single_node_no_division_by_zero() {
651        let nodes: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
652        let scores = brandes_betweenness(&nodes, &[]);
653        assert_eq!(*scores.get("a").unwrap_or(&0.0), 0.0);
654    }
655
656    #[test]
657    fn brandes_empty_graph() {
658        let nodes: HashSet<String> = HashSet::new();
659        let scores = brandes_betweenness(&nodes, &[]);
660        assert!(scores.is_empty());
661    }
662
663    #[test]
664    fn brandes_only_uses_high_confidence_edges() {
665        let edges = vec![
666            make_edge(EdgeKind::Calls, "a", "b"),
667            make_edge(EdgeKind::ImportsFrom, "a", "c"),
668        ];
669        let nodes: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
670        let scores = brandes_betweenness(&nodes, &edges);
671        assert_eq!(*scores.get("c").unwrap_or(&0.0), 0.0);
672    }
673
674    #[test]
675    fn brandes_normalization_directed() {
676        let edges = vec![
677            make_edge(EdgeKind::Calls, "a", "b"),
678            make_edge(EdgeKind::Calls, "b", "c"),
679            make_edge(EdgeKind::Calls, "c", "d"),
680            make_edge(EdgeKind::Calls, "d", "e"),
681        ];
682        let nodes: HashSet<String> = ["a", "b", "c", "d", "e"]
683            .iter()
684            .map(|s| s.to_string())
685            .collect();
686        let scores = brandes_betweenness(&nodes, &edges);
687        for &v in scores.values() {
688            assert!(v <= 1.0, "normalized score must not exceed 1.0");
689        }
690        // In directed A→B→C→D→E, C's raw betweenness = 4 (on paths A→D, A→E, B→D, B→E).
691        // Normalized: 4 / ((5-1)(5-2)) = 4/12 = 1/3
692        let c_score = scores.get("c").copied().unwrap_or(0.0);
693        assert!(
694            (c_score - 1.0 / 3.0).abs() < 1e-9,
695            "center of 5-node directed linear graph should have betweenness 1/3, got {c_score}"
696        );
697    }
698
699    // -----------------------------------------------------------------------
700    // T04: Flow enumeration tests
701    // -----------------------------------------------------------------------
702
703    #[test]
704    fn enumerate_flows_linear_graph() {
705        let edges = vec![
706            make_edge(EdgeKind::Calls, "main", "a"),
707            make_edge(EdgeKind::Calls, "a", "b"),
708        ];
709        let entry_points = vec![EntryPoint {
710            qualified_name: "main".into(),
711            kind: EntryPointKind::Main,
712            confidence: 1.0,
713        }];
714        let flows = enumerate_flows(&entry_points, &edges, &FlowConfig::default());
715        assert_eq!(flows.len(), 1);
716        assert_eq!(flows[0].path, vec!["main", "a", "b"]);
717        assert_eq!(flows[0].depth, 3);
718        assert!(!flows[0].truncated);
719    }
720
721    #[test]
722    fn enumerate_flows_cycle_detection() {
723        let edges = vec![
724            make_edge(EdgeKind::Calls, "main", "a"),
725            make_edge(EdgeKind::Calls, "a", "b"),
726            make_edge(EdgeKind::Calls, "b", "a"),
727        ];
728        let entry_points = vec![EntryPoint {
729            qualified_name: "main".into(),
730            kind: EntryPointKind::Main,
731            confidence: 1.0,
732        }];
733        let flows = enumerate_flows(&entry_points, &edges, &FlowConfig::default());
734        for flow in &flows {
735            let unique: HashSet<&String> = flow.path.iter().collect();
736            assert_eq!(unique.len(), flow.path.len(), "no duplicates in flow path");
737        }
738    }
739
740    #[test]
741    fn enumerate_flows_depth_limit() {
742        let edges: Vec<Edge> = (0..25)
743            .map(|i| make_edge(EdgeKind::Calls, &format!("e{i}"), &format!("e{}", i + 1)))
744            .collect();
745        let entry_points = vec![EntryPoint {
746            qualified_name: "e0".into(),
747            kind: EntryPointKind::Main,
748            confidence: 1.0,
749        }];
750        let config = FlowConfig {
751            max_depth: 5,
752            ..FlowConfig::default()
753        };
754        let flows = enumerate_flows(&entry_points, &edges, &config);
755        for flow in &flows {
756            assert!(flow.path.len() <= 5, "flow depth must not exceed max_depth");
757        }
758    }
759
760    #[test]
761    fn enumerate_flows_global_cap() {
762        let edges: Vec<Edge> = (0..100)
763            .map(|i| make_edge(EdgeKind::Calls, "entry", &format!("a{i}")))
764            .collect();
765        let entry_points = vec![EntryPoint {
766            qualified_name: "entry".into(),
767            kind: EntryPointKind::Main,
768            confidence: 1.0,
769        }];
770        let config = FlowConfig {
771            max_flows: 10,
772            ..FlowConfig::default()
773        };
774        let flows = enumerate_flows(&entry_points, &edges, &config);
775        assert!(flows.len() <= 10, "global flow cap must be respected");
776    }
777
778    #[test]
779    fn enumerate_flows_visit_budget() {
780        let edges: Vec<Edge> = (0..1000)
781            .map(|i| make_edge(EdgeKind::Calls, "entry", &format!("a{i}")))
782            .collect();
783        let entry_points = vec![EntryPoint {
784            qualified_name: "entry".into(),
785            kind: EntryPointKind::Main,
786            confidence: 1.0,
787        }];
788        let config = FlowConfig {
789            visit_budget: 50,
790            ..FlowConfig::default()
791        };
792        let flows = enumerate_flows(&entry_points, &edges, &config);
793        assert!(flows.iter().any(|f| f.truncated) || flows.len() < 1000);
794    }
795
796    #[test]
797    fn enumerate_flows_only_high_confidence_edges() {
798        let edges = vec![
799            make_edge(EdgeKind::Calls, "main", "a"),
800            make_edge(EdgeKind::ImportsFrom, "a", "b"),
801        ];
802        let entry_points = vec![EntryPoint {
803            qualified_name: "main".into(),
804            kind: EntryPointKind::Main,
805            confidence: 1.0,
806        }];
807        let flows = enumerate_flows(&entry_points, &edges, &FlowConfig::default());
808        for flow in &flows {
809            assert!(!flow.path.contains(&"b".to_string()));
810        }
811    }
812
813    #[test]
814    fn enumerate_flows_multiple_entry_points_share_global_cap() {
815        let mut edges = Vec::new();
816        for i in 0..10 {
817            edges.push(make_edge(EdgeKind::Calls, "e1", &format!("a{i}")));
818            edges.push(make_edge(EdgeKind::Calls, "e2", &format!("b{i}")));
819        }
820        let entry_points = vec![
821            EntryPoint {
822                qualified_name: "e1".into(),
823                kind: EntryPointKind::Main,
824                confidence: 1.0,
825            },
826            EntryPoint {
827                qualified_name: "e2".into(),
828                kind: EntryPointKind::Test,
829                confidence: 1.0,
830            },
831        ];
832        let config = FlowConfig {
833            max_flows: 15,
834            ..FlowConfig::default()
835        };
836        let flows = enumerate_flows(&entry_points, &edges, &config);
837        assert!(
838            flows.len() <= 15,
839            "global cap must be shared across entry points"
840        );
841        let e1_flows = flows.iter().filter(|f| f.entry == "e1").count();
842        let e2_flows = flows.iter().filter(|f| f.entry == "e2").count();
843        assert!(
844            e1_flows > 0 && e2_flows > 0,
845            "both entry points should contribute flows"
846        );
847    }
848
849    #[test]
850    fn enumerate_flows_branching() {
851        let edges = vec![
852            make_edge(EdgeKind::Calls, "main", "a"),
853            make_edge(EdgeKind::Calls, "main", "b"),
854            make_edge(EdgeKind::Calls, "a", "c"),
855            make_edge(EdgeKind::Calls, "b", "c"),
856        ];
857        let entry_points = vec![EntryPoint {
858            qualified_name: "main".into(),
859            kind: EntryPointKind::Main,
860            confidence: 1.0,
861        }];
862        let flows = enumerate_flows(&entry_points, &edges, &FlowConfig::default());
863        assert_eq!(flows.len(), 2);
864    }
865}