Skip to main content

sqry_core/graph/unified/
kernel.rs

1//! Generic BFS kernel with `TraversalConfig` and strategy callbacks.
2//!
3//! Provides a single `traverse()` function that implements standard BFS (global
4//! visited set) and path-enumeration BFS (path-local cycle detection). All 14
5//! BFS implementations in sqry migrate to this kernel.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::ops::ControlFlow;
9
10use super::concurrent::GraphSnapshot;
11use super::edge::kind::EdgeKind;
12use super::edge::store::StoreEdgeRef;
13use super::materialize::materialize_node;
14use super::node::id::NodeId;
15use super::traversal::{
16    EdgeClassification, MaterializedEdge, TraversalMetadata, TraversalResult, TruncationReason,
17};
18
19// ──────────────────── Configuration types ────────────────────
20
21/// Which direction to traverse edges.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TraversalDirection {
24    /// Traverse outgoing edges (callees, dependencies).
25    Outgoing,
26    /// Traverse incoming edges (callers, reverse impact).
27    Incoming,
28    /// Traverse both directions (subgraph extraction).
29    Both,
30}
31
32/// Filter controlling which edge types to include during traversal.
33///
34/// Each boolean corresponds to an `EdgeClassification` variant group.
35/// Edges whose classification does not match any enabled flag are skipped.
36#[derive(Debug, Clone)]
37#[allow(clippy::struct_excessive_bools)] // One bool per edge classification variant is the natural API
38pub struct EdgeFilter {
39    /// Include `Call` edges.
40    pub include_calls: bool,
41    /// Include `Import` and `Export` edges.
42    pub include_imports: bool,
43    /// Include `Reference` edges.
44    pub include_references: bool,
45    /// Include `Inherits` and `Implements` edges.
46    pub include_inheritance: bool,
47    /// Include `Contains` and `Defines` edges.
48    pub include_structural: bool,
49    /// Include `TypeOf` edges.
50    pub include_type_edges: bool,
51    /// Include `DatabaseAccess` edges.
52    pub include_database: bool,
53    /// Include `ServiceInteraction` edges.
54    pub include_service: bool,
55}
56
57impl EdgeFilter {
58    /// All edge types included.
59    #[must_use]
60    pub const fn all() -> Self {
61        Self {
62            include_calls: true,
63            include_imports: true,
64            include_references: true,
65            include_inheritance: true,
66            include_structural: true,
67            include_type_edges: true,
68            include_database: true,
69            include_service: true,
70        }
71    }
72
73    /// Only call edges.
74    #[must_use]
75    pub const fn calls_only() -> Self {
76        Self {
77            include_calls: true,
78            include_imports: false,
79            include_references: false,
80            include_inheritance: false,
81            include_structural: false,
82            include_type_edges: false,
83            include_database: false,
84            include_service: false,
85        }
86    }
87
88    /// Calls and imports.
89    #[must_use]
90    pub const fn calls_and_imports() -> Self {
91        Self {
92            include_calls: true,
93            include_imports: true,
94            include_references: false,
95            include_inheritance: false,
96            include_structural: false,
97            include_type_edges: false,
98            include_database: false,
99            include_service: false,
100        }
101    }
102
103    /// Calls, imports, references, and inheritance (dependency impact).
104    #[must_use]
105    pub const fn dependency_edges() -> Self {
106        Self {
107            include_calls: true,
108            include_imports: true,
109            include_references: true,
110            include_inheritance: true,
111            include_structural: false,
112            include_type_edges: false,
113            include_database: false,
114            include_service: false,
115        }
116    }
117
118    /// Returns `true` if the given classification passes the filter.
119    #[must_use]
120    pub fn accepts(&self, classification: &EdgeClassification) -> bool {
121        match classification {
122            EdgeClassification::Call { .. } => self.include_calls,
123            EdgeClassification::Import { .. } | EdgeClassification::Export { .. } => {
124                self.include_imports
125            }
126            EdgeClassification::Reference => self.include_references,
127            EdgeClassification::Inherits | EdgeClassification::Implements => {
128                self.include_inheritance
129            }
130            EdgeClassification::Contains | EdgeClassification::Defines => self.include_structural,
131            EdgeClassification::TypeOf => self.include_type_edges,
132            EdgeClassification::DatabaseAccess => self.include_database,
133            EdgeClassification::ServiceInteraction => self.include_service,
134        }
135    }
136}
137
138/// Resource limits for traversal.
139#[derive(Debug, Clone)]
140pub struct TraversalLimits {
141    /// Maximum BFS depth.
142    pub max_depth: u32,
143    /// Maximum number of nodes to collect.
144    pub max_nodes: Option<usize>,
145    /// Maximum number of edges to collect.
146    pub max_edges: Option<usize>,
147    /// Maximum number of paths to collect (path enumeration only).
148    pub max_paths: Option<usize>,
149}
150
151impl TraversalLimits {
152    /// Default limits for subgraph extraction.
153    #[must_use]
154    pub const fn default_subgraph() -> Self {
155        Self {
156            max_depth: 2,
157            max_nodes: Some(50),
158            max_edges: None,
159            max_paths: None,
160        }
161    }
162
163    /// Default limits for graph export.
164    #[must_use]
165    pub const fn default_export() -> Self {
166        Self {
167            max_depth: 2,
168            max_nodes: None,
169            max_edges: Some(1000),
170            max_paths: None,
171        }
172    }
173
174    /// Default limits for dependency impact analysis.
175    #[must_use]
176    pub const fn default_impact() -> Self {
177        Self {
178            max_depth: 3,
179            max_nodes: Some(500),
180            max_edges: None,
181            max_paths: None,
182        }
183    }
184
185    /// Default limits for path tracing.
186    #[must_use]
187    pub const fn default_trace() -> Self {
188        Self {
189            max_depth: 5,
190            max_nodes: None,
191            max_edges: None,
192            max_paths: Some(5),
193        }
194    }
195}
196
197/// Complete traversal configuration.
198#[derive(Debug, Clone)]
199pub struct TraversalConfig {
200    /// Which direction to traverse edges.
201    pub direction: TraversalDirection,
202    /// Which edge types to include/traverse.
203    pub edge_filter: EdgeFilter,
204    /// Resource limits.
205    pub limits: TraversalLimits,
206}
207
208// ──────────────────── Strategy trait ────────────────────
209
210/// What the BFS frontier queue stores.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum FrontierMode {
213    /// Queue stores `(NodeId, depth)`. Used by standard BFS consumers.
214    Standard,
215    /// Queue stores `(NodeId, Vec<NodeId> path, depth)`. Used by `trace_path`.
216    PathEnumeration,
217}
218
219/// How dedup/cycle-detection works.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum VisitedPolicy {
222    /// Global `HashSet<NodeId>`. Never re-enqueue visited nodes.
223    Global,
224    /// No global visited set. Cycle check against in-flight path only.
225    /// Allows same node in multiple alternate paths.
226    PathLocal,
227}
228
229/// Strategy callbacks for customizing traversal behavior.
230///
231/// Default implementations produce standard BFS with global visited set.
232pub trait TraversalStrategy {
233    /// Filter edges before processing. Access raw edge for confidence/followability.
234    fn accept_edge(&mut self, _edge: &StoreEdgeRef, _depth: u32) -> bool {
235        true
236    }
237
238    /// Control enqueueing with full context. Supports SCC pruning.
239    fn should_enqueue(
240        &mut self,
241        _node_id: NodeId,
242        _from: NodeId,
243        _edge: &EdgeKind,
244        _depth: u32,
245    ) -> bool {
246        true
247    }
248
249    /// Called when a path reaches target (`PathEnumeration` mode only).
250    /// Return `Break` to stop collecting paths.
251    fn on_path_complete(&mut self, _path: &[NodeId]) -> ControlFlow<()> {
252        ControlFlow::Continue(())
253    }
254
255    /// What the queue stores.
256    fn frontier_mode(&self) -> FrontierMode {
257        FrontierMode::Standard
258    }
259
260    /// How dedup/cycle-detection works.
261    fn visited_policy(&self) -> VisitedPolicy {
262        VisitedPolicy::Global
263    }
264
265    /// Path target for `PathEnumeration` mode. `None` = enumerate all paths to leaves.
266    fn path_target(&self) -> Option<NodeId> {
267        None
268    }
269}
270
271/// Default no-op strategy for standard BFS.
272struct DefaultStrategy;
273impl TraversalStrategy for DefaultStrategy {}
274
275// ──────────────────── Edge followability ────────────────────
276
277/// Returns `true` if an edge kind should be followed during path traversal
278/// at the given minimum confidence threshold.
279///
280/// Confidence mapping:
281/// - `Calls`, `TraitMethodBinding`: 1.0 (highest confidence)
282/// - `Inherits`, `Implements`: 0.9
283/// - `Imports`, `Exports`: 0.8
284/// - `References`: 0.7
285/// - Cross-boundary calls (`FfiCall`, `HttpRequest`, `GrpcCall`, `WebAssemblyCall`): 0.6
286/// - `MacroExpansion`: 0.5
287/// - Everything else: 0.3
288#[must_use]
289pub fn is_followable_edge(kind: &EdgeKind, min_confidence: f64) -> bool {
290    let confidence = match kind {
291        EdgeKind::Calls { .. } | EdgeKind::TraitMethodBinding { .. } => 1.0,
292        EdgeKind::Inherits | EdgeKind::Implements | EdgeKind::SealedPermit => 0.9,
293        EdgeKind::Imports { .. } | EdgeKind::Exports { .. } => 0.8,
294        EdgeKind::References => 0.7,
295        EdgeKind::FfiCall { .. }
296        | EdgeKind::HttpRequest { .. }
297        | EdgeKind::GrpcCall { .. }
298        | EdgeKind::WebAssemblyCall => 0.6,
299        EdgeKind::MacroExpansion { .. } => 0.5,
300        _ => 0.3,
301    };
302    confidence >= min_confidence
303}
304
305// ──────────────────── Built-in strategies ────────────────────
306
307/// Simple path enumeration strategy for `trace_path` (non-optimized variant).
308///
309/// Traverses all paths from seeds to `target`, filtering edges by minimum
310/// confidence and optionally restricting to same-language edges.
311pub struct SimplePathStrategy {
312    /// Target node to find paths to.
313    target: NodeId,
314    /// Minimum confidence threshold for edge followability.
315    min_confidence: f64,
316    /// Whether to allow cross-language edges.
317    allow_cross_language: bool,
318}
319
320impl SimplePathStrategy {
321    /// Creates a new simple path strategy.
322    #[must_use]
323    pub fn new(target: NodeId, min_confidence: f64, allow_cross_language: bool) -> Self {
324        Self {
325            target,
326            min_confidence,
327            allow_cross_language,
328        }
329    }
330}
331
332impl TraversalStrategy for SimplePathStrategy {
333    fn accept_edge(&mut self, edge: &StoreEdgeRef, _depth: u32) -> bool {
334        is_followable_edge(&edge.kind, self.min_confidence)
335            && (self.allow_cross_language || !edge.kind.is_cross_boundary())
336    }
337
338    fn frontier_mode(&self) -> FrontierMode {
339        FrontierMode::PathEnumeration
340    }
341
342    fn visited_policy(&self) -> VisitedPolicy {
343        VisitedPolicy::PathLocal
344    }
345
346    fn path_target(&self) -> Option<NodeId> {
347        Some(self.target)
348    }
349}
350
351/// SCC-pruned path enumeration strategy for `trace_path` (optimized variant).
352///
353/// Uses precomputed SCC data and condensation DAG to prune branches that
354/// cannot reach the target node's SCC component.
355pub struct SccPathStrategy<'a> {
356    /// Precomputed SCC data.
357    scc_data: &'a super::analysis::scc::SccData,
358    /// Precomputed condensation DAG for reachability queries.
359    cond_dag: &'a super::analysis::condensation::CondensationDag,
360    /// Target node.
361    target: NodeId,
362    /// SCC index of the target node (cached).
363    target_scc: Option<u32>,
364    /// Minimum confidence threshold for edge followability.
365    min_confidence: f64,
366    /// Whether to allow cross-language edges.
367    allow_cross_language: bool,
368}
369
370impl<'a> SccPathStrategy<'a> {
371    /// Creates a new SCC-pruned path strategy.
372    #[must_use]
373    pub fn new(
374        scc_data: &'a super::analysis::scc::SccData,
375        cond_dag: &'a super::analysis::condensation::CondensationDag,
376        target: NodeId,
377        min_confidence: f64,
378        allow_cross_language: bool,
379    ) -> Self {
380        let target_scc = scc_data.scc_of(target);
381        Self {
382            scc_data,
383            cond_dag,
384            target,
385            target_scc,
386            min_confidence,
387            allow_cross_language,
388        }
389    }
390}
391
392impl TraversalStrategy for SccPathStrategy<'_> {
393    fn accept_edge(&mut self, edge: &StoreEdgeRef, _depth: u32) -> bool {
394        is_followable_edge(&edge.kind, self.min_confidence)
395            && (self.allow_cross_language || !edge.kind.is_cross_boundary())
396    }
397
398    fn should_enqueue(
399        &mut self,
400        node_id: NodeId,
401        _from: NodeId,
402        _edge: &EdgeKind,
403        _depth: u32,
404    ) -> bool {
405        // If target SCC is unknown, allow all (conservative)
406        let Some(target_scc) = self.target_scc else {
407            return true;
408        };
409
410        // If node's SCC is unknown, allow (conservative)
411        let Some(node_scc) = self.scc_data.scc_of(node_id) else {
412            return true;
413        };
414
415        self.cond_dag.can_reach(node_scc, target_scc)
416    }
417
418    fn on_path_complete(&mut self, _path: &[NodeId]) -> ControlFlow<()> {
419        ControlFlow::Continue(())
420    }
421
422    fn frontier_mode(&self) -> FrontierMode {
423        FrontierMode::PathEnumeration
424    }
425
426    fn visited_policy(&self) -> VisitedPolicy {
427        VisitedPolicy::PathLocal
428    }
429
430    fn path_target(&self) -> Option<NodeId> {
431        Some(self.target)
432    }
433}
434
435// ──────────────────── Public API ────────────────────
436
437/// Execute a graph traversal from the given seeds.
438///
439/// With `strategy: None`, performs standard BFS with global visited set.
440/// With a strategy, behavior is customized via the trait methods.
441#[must_use]
442pub fn traverse(
443    snapshot: &GraphSnapshot,
444    seeds: &[NodeId],
445    config: &TraversalConfig,
446    strategy: Option<&mut dyn TraversalStrategy>,
447) -> TraversalResult {
448    let mut default_strategy = DefaultStrategy;
449    let strategy = strategy.unwrap_or(&mut default_strategy);
450
451    let frontier_mode = strategy.frontier_mode();
452    let visited_policy = strategy.visited_policy();
453
454    match (frontier_mode, visited_policy) {
455        (FrontierMode::Standard, _) => {
456            // Standard + PathLocal is invalid, treat as Standard + Global
457            run_standard_bfs(snapshot, seeds, config, strategy)
458        }
459        (FrontierMode::PathEnumeration, VisitedPolicy::PathLocal) => {
460            run_path_bfs(snapshot, seeds, config, strategy)
461        }
462        (FrontierMode::PathEnumeration, VisitedPolicy::Global) => {
463            // Unusual but supported: path enumeration with global visited
464            run_path_bfs(snapshot, seeds, config, strategy)
465        }
466    }
467}
468
469// ──────────────────── Standard BFS ────────────────────
470
471/// Raw edge tuple collected during BFS.
472struct RawEdge {
473    source: NodeId,
474    target: NodeId,
475    kind: EdgeKind,
476    depth: u32,
477}
478
479/// Standard BFS with global visited set.
480fn run_standard_bfs(
481    snapshot: &GraphSnapshot,
482    seeds: &[NodeId],
483    config: &TraversalConfig,
484    strategy: &mut dyn TraversalStrategy,
485) -> TraversalResult {
486    let mut visited: HashSet<NodeId> = HashSet::new();
487    let mut queue: VecDeque<(NodeId, u32)> = VecDeque::new();
488    let mut raw_edges: Vec<RawEdge> = Vec::new();
489    let mut discovered_order: Vec<NodeId> = Vec::new();
490    let mut truncation: Option<TruncationReason> = None;
491    let mut max_depth_reached = false;
492    let mut nodes_visited: usize = 0;
493
494    // Seed the queue
495    for &seed in seeds {
496        if visited.insert(seed) {
497            discovered_order.push(seed);
498            queue.push_back((seed, 0));
499        }
500    }
501
502    'bfs: while let Some((current, depth)) = queue.pop_front() {
503        nodes_visited += 1;
504
505        if depth >= config.limits.max_depth {
506            max_depth_reached = true;
507            continue;
508        }
509
510        // Check node limit
511        if let Some(max_nodes) = config.limits.max_nodes
512            && discovered_order.len() >= max_nodes
513        {
514            truncation = Some(TruncationReason::NodeLimit);
515            break;
516        }
517
518        let edges = collect_edges(snapshot, current, config.direction);
519
520        for edge_ref in &edges {
521            // Check edge limit
522            if let Some(max_edges) = config.limits.max_edges
523                && raw_edges.len() >= max_edges
524            {
525                truncation = Some(TruncationReason::EdgeLimit);
526                break 'bfs;
527            }
528
529            let classification = EdgeClassification::from(&edge_ref.kind);
530            if !config.edge_filter.accepts(&classification) {
531                continue;
532            }
533
534            if !strategy.accept_edge(edge_ref, depth) {
535                continue;
536            }
537
538            // Determine the "next" node depending on direction
539            let next = neighbor_of(edge_ref, current);
540
541            raw_edges.push(RawEdge {
542                source: edge_ref.source,
543                target: edge_ref.target,
544                kind: edge_ref.kind.clone(),
545                depth: depth + 1,
546            });
547
548            if visited.insert(next)
549                && strategy.should_enqueue(next, current, &edge_ref.kind, depth + 1)
550            {
551                // Enforce node limit before adding
552                if let Some(max_nodes) = config.limits.max_nodes
553                    && discovered_order.len() >= max_nodes
554                {
555                    truncation = Some(TruncationReason::NodeLimit);
556                    break 'bfs;
557                }
558                discovered_order.push(next);
559                queue.push_back((next, depth + 1));
560            }
561        }
562    }
563
564    materialize_result(
565        snapshot,
566        &discovered_order,
567        &raw_edges,
568        None,
569        truncation,
570        max_depth_reached,
571        seeds.len(),
572        nodes_visited,
573    )
574}
575
576// ──────────────────── Path enumeration BFS (stub for Task 4) ────────────────────
577
578/// Path enumeration BFS with path-local cycle detection.
579#[allow(clippy::too_many_lines)] // Kernel query dispatches across all edge kinds
580#[allow(clippy::too_many_lines)] // Complex path-tracking BFS state machine — extraction would obscure the algorithm
581fn run_path_bfs(
582    snapshot: &GraphSnapshot,
583    seeds: &[NodeId],
584    config: &TraversalConfig,
585    strategy: &mut dyn TraversalStrategy,
586) -> TraversalResult {
587    let target = strategy.path_target();
588    let mut collected_paths: Vec<Vec<NodeId>> = Vec::new();
589    let mut discovered_order: Vec<NodeId> = Vec::new();
590    let mut seen: HashSet<NodeId> = HashSet::new();
591    let mut raw_edges: Vec<RawEdge> = Vec::new();
592    let mut truncation: Option<TruncationReason> = None;
593    let mut max_depth_reached = false;
594    let mut nodes_visited: usize = 0;
595
596    // Queue: (current_node, path_so_far, depth)
597    let mut queue: VecDeque<(NodeId, Vec<NodeId>, u32)> = VecDeque::new();
598
599    for &seed in seeds {
600        queue.push_back((seed, vec![seed], 0));
601        if seen.insert(seed) {
602            discovered_order.push(seed);
603        }
604    }
605
606    let use_global_visited = strategy.visited_policy() == VisitedPolicy::Global;
607    let mut global_visited: HashSet<NodeId> = if use_global_visited {
608        seeds.iter().copied().collect()
609    } else {
610        HashSet::new()
611    };
612
613    'bfs: while let Some((current, path, depth)) = queue.pop_front() {
614        nodes_visited += 1;
615
616        // Check if we reached the target
617        if let Some(t) = target
618            && current == t
619            && path.len() > 1
620        {
621            // Record nodes along the path in discovery order
622            for &node in &path {
623                if seen.insert(node) {
624                    discovered_order.push(node);
625                }
626            }
627
628            let control = strategy.on_path_complete(&path);
629            collected_paths.push(path);
630
631            // Check path limit
632            if let Some(max_paths) = config.limits.max_paths
633                && collected_paths.len() >= max_paths
634            {
635                truncation = Some(TruncationReason::PathLimit);
636                break 'bfs;
637            }
638
639            if control.is_break() {
640                break 'bfs;
641            }
642            continue;
643        }
644
645        if depth >= config.limits.max_depth {
646            max_depth_reached = true;
647            continue;
648        }
649
650        // Check node limit
651        if let Some(max_nodes) = config.limits.max_nodes
652            && discovered_order.len() >= max_nodes
653        {
654            truncation = Some(TruncationReason::NodeLimit);
655            break;
656        }
657
658        let edges = collect_edges(snapshot, current, config.direction);
659
660        // If no target specified, leaf nodes (no followable outgoing edges) complete a path
661        let mut has_followable_successor = false;
662
663        for edge_ref in &edges {
664            // Check edge limit
665            if let Some(max_edges) = config.limits.max_edges
666                && raw_edges.len() >= max_edges
667            {
668                truncation = Some(TruncationReason::EdgeLimit);
669                break 'bfs;
670            }
671
672            let classification = EdgeClassification::from(&edge_ref.kind);
673            if !config.edge_filter.accepts(&classification) {
674                continue;
675            }
676
677            if !strategy.accept_edge(edge_ref, depth) {
678                continue;
679            }
680
681            let next = neighbor_of(edge_ref, current);
682
683            // Cycle detection: path-local or global
684            if use_global_visited {
685                if !global_visited.insert(next) {
686                    continue;
687                }
688            } else if path.contains(&next) {
689                continue;
690            }
691
692            if !strategy.should_enqueue(next, current, &edge_ref.kind, depth + 1) {
693                continue;
694            }
695
696            has_followable_successor = true;
697
698            raw_edges.push(RawEdge {
699                source: edge_ref.source,
700                target: edge_ref.target,
701                kind: edge_ref.kind.clone(),
702                depth: depth + 1,
703            });
704
705            // Enforce node limit before adding
706            if let Some(max_nodes) = config.limits.max_nodes
707                && discovered_order.len() >= max_nodes
708            {
709                truncation = Some(TruncationReason::NodeLimit);
710                break 'bfs;
711            }
712
713            if seen.insert(next) {
714                discovered_order.push(next);
715            }
716
717            let mut new_path = path.clone();
718            new_path.push(next);
719            queue.push_back((next, new_path, depth + 1));
720        }
721
722        // Leaf enumeration: if no target specified and node is a leaf, record path
723        if target.is_none() && !has_followable_successor && path.len() > 1 {
724            for &node in &path {
725                if seen.insert(node) {
726                    discovered_order.push(node);
727                }
728            }
729
730            let control = strategy.on_path_complete(&path);
731            collected_paths.push(path);
732
733            if let Some(max_paths) = config.limits.max_paths
734                && collected_paths.len() >= max_paths
735            {
736                truncation = Some(TruncationReason::PathLimit);
737                break 'bfs;
738            }
739
740            if control.is_break() {
741                break 'bfs;
742            }
743        }
744    }
745
746    let paths_for_result = Some(collected_paths);
747
748    materialize_result(
749        snapshot,
750        &discovered_order,
751        &raw_edges,
752        paths_for_result,
753        truncation,
754        max_depth_reached,
755        seeds.len(),
756        nodes_visited,
757    )
758}
759
760// ──────────────────── Shared helpers ────────────────────
761
762/// Collect edges for a node in the given direction.
763fn collect_edges(
764    snapshot: &GraphSnapshot,
765    node: NodeId,
766    direction: TraversalDirection,
767) -> Vec<StoreEdgeRef> {
768    match direction {
769        TraversalDirection::Outgoing => snapshot.edges().edges_from(node),
770        TraversalDirection::Incoming => snapshot.edges().edges_to(node),
771        TraversalDirection::Both => {
772            let mut edges = snapshot.edges().edges_from(node);
773            edges.extend(snapshot.edges().edges_to(node));
774            edges
775        }
776    }
777}
778
779/// Given an edge and the current node, return the neighbor to traverse to.
780fn neighbor_of(edge_ref: &StoreEdgeRef, current: NodeId) -> NodeId {
781    if edge_ref.source == current {
782        edge_ref.target
783    } else {
784        edge_ref.source
785    }
786}
787
788/// Materialize collected BFS results into a `TraversalResult`.
789///
790/// Converts discovered `NodeId`s into `MaterializedNode`s, builds the index map,
791/// converts raw edges to `MaterializedEdge`s, and optionally converts paths.
792#[allow(clippy::too_many_arguments)]
793fn materialize_result(
794    snapshot: &GraphSnapshot,
795    discovered_order: &[NodeId],
796    raw_edges: &[RawEdge],
797    raw_paths: Option<Vec<Vec<NodeId>>>,
798    truncation: Option<TruncationReason>,
799    max_depth_reached: bool,
800    seed_count: usize,
801    nodes_visited: usize,
802) -> TraversalResult {
803    // Materialize nodes and build index map
804    let mut nodes = Vec::with_capacity(discovered_order.len());
805    let mut node_index: HashMap<NodeId, usize> = HashMap::with_capacity(discovered_order.len());
806
807    for &node_id in discovered_order {
808        if node_index.contains_key(&node_id) {
809            continue;
810        }
811        if let Some(materialized) = materialize_node(snapshot, node_id) {
812            let idx = nodes.len();
813            node_index.insert(node_id, idx);
814            nodes.push(materialized);
815        }
816    }
817
818    // Convert raw edges to MaterializedEdge, dropping any with missing indices
819    let mut edges = Vec::with_capacity(raw_edges.len());
820    for raw in raw_edges {
821        if let (Some(&source_idx), Some(&target_idx)) =
822            (node_index.get(&raw.source), node_index.get(&raw.target))
823        {
824            edges.push(MaterializedEdge {
825                source_idx,
826                target_idx,
827                classification: EdgeClassification::from(&raw.kind),
828                raw_kind: raw.kind.clone(),
829                depth: raw.depth,
830            });
831        }
832    }
833
834    // Deduplicate edges (same source_idx, target_idx, classification)
835    edges.dedup_by(|a, b| {
836        a.source_idx == b.source_idx
837            && a.target_idx == b.target_idx
838            && a.classification == b.classification
839    });
840
841    // Convert paths
842    let paths = raw_paths.map(|path_list| {
843        path_list
844            .iter()
845            .filter_map(|path| {
846                let converted: Vec<usize> = path
847                    .iter()
848                    .filter_map(|node_id| node_index.get(node_id).copied())
849                    .collect();
850                // Drop paths where any node couldn't be materialized
851                if converted.len() == path.len() {
852                    Some(converted)
853                } else {
854                    None
855                }
856            })
857            .collect()
858    });
859
860    TraversalResult {
861        metadata: TraversalMetadata {
862            truncation,
863            max_depth_reached,
864            seed_count,
865            nodes_visited,
866            total_nodes: nodes.len(),
867            total_edges: edges.len(),
868        },
869        nodes,
870        edges,
871        paths,
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::graph::node::Language;
879    use crate::graph::unified::concurrent::CodeGraph;
880    use crate::graph::unified::node::kind::NodeKind;
881    use crate::graph::unified::storage::arena::NodeEntry;
882
883    use crate::graph::unified::file::FileId;
884
885    /// Helper to create a test graph with nodes and edges.
886    struct TestGraph {
887        graph: CodeGraph,
888        file_id: Option<FileId>,
889    }
890
891    impl TestGraph {
892        fn new() -> Self {
893            Self {
894                graph: CodeGraph::new(),
895                file_id: None,
896            }
897        }
898
899        fn ensure_file_id(&mut self) -> FileId {
900            if let Some(fid) = self.file_id {
901                return fid;
902            }
903            let file_path = std::path::PathBuf::from("/kernel-tests/test.rs");
904            let fid = self
905                .graph
906                .files_mut()
907                .register_with_language(&file_path, Some(Language::Rust))
908                .unwrap();
909            self.file_id = Some(fid);
910            fid
911        }
912
913        fn add_node(&mut self, name: &str) -> NodeId {
914            self.add_node_with_kind(name, NodeKind::Function)
915        }
916
917        fn add_node_with_kind(&mut self, name: &str, kind: NodeKind) -> NodeId {
918            let file_id = self.ensure_file_id();
919            let name_id = self.graph.strings_mut().intern(name).unwrap();
920            let qn_id = self
921                .graph
922                .strings_mut()
923                .intern(&format!("test::{name}"))
924                .unwrap();
925
926            let entry = NodeEntry::new(kind, name_id, file_id)
927                .with_qualified_name(qn_id)
928                .with_location(1, 0, 10, 0);
929
930            let node_id = self.graph.nodes_mut().alloc(entry).unwrap();
931            self.graph
932                .indices_mut()
933                .add(node_id, kind, name_id, Some(qn_id), file_id);
934            node_id
935        }
936
937        fn add_call_edge(&mut self, source: NodeId, target: NodeId) {
938            let file_id = self.ensure_file_id();
939            self.graph.edges_mut().add_edge(
940                source,
941                target,
942                EdgeKind::Calls {
943                    argument_count: 0,
944                    is_async: false,
945                },
946                file_id,
947            );
948        }
949
950        fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) {
951            let file_id = self.ensure_file_id();
952            self.graph
953                .edges_mut()
954                .add_edge(source, target, kind, file_id);
955        }
956
957        fn snapshot(&self) -> GraphSnapshot {
958            self.graph.snapshot()
959        }
960    }
961
962    fn calls_config(depth: u32) -> TraversalConfig {
963        TraversalConfig {
964            direction: TraversalDirection::Outgoing,
965            edge_filter: EdgeFilter::calls_only(),
966            limits: TraversalLimits {
967                max_depth: depth,
968                max_nodes: None,
969                max_edges: None,
970                max_paths: None,
971            },
972        }
973    }
974
975    #[test]
976    fn standard_outgoing_bfs() {
977        let mut tg = TestGraph::new();
978        let a = tg.add_node("a");
979        let b = tg.add_node("b");
980        let c = tg.add_node("c");
981        tg.add_call_edge(a, b);
982        tg.add_call_edge(b, c);
983
984        let snapshot = tg.snapshot();
985        let result = traverse(&snapshot, &[a], &calls_config(3), None);
986
987        assert_eq!(result.nodes.len(), 3);
988        assert_eq!(result.edges.len(), 2);
989        assert!(result.metadata.truncation.is_none());
990        // First node should be the seed
991        assert_eq!(result.nodes[0].node_id, a);
992    }
993
994    #[test]
995    fn depth_limit() {
996        let mut tg = TestGraph::new();
997        let a = tg.add_node("a");
998        let b = tg.add_node("b");
999        let c = tg.add_node("c");
1000        tg.add_call_edge(a, b);
1001        tg.add_call_edge(b, c);
1002
1003        let snapshot = tg.snapshot();
1004        let result = traverse(&snapshot, &[a], &calls_config(1), None);
1005
1006        // Depth 1: should discover a and b, but not c (b→c is at depth 2)
1007        assert_eq!(result.nodes.len(), 2);
1008        assert_eq!(result.edges.len(), 1);
1009        assert!(result.metadata.max_depth_reached);
1010    }
1011
1012    #[test]
1013    fn node_limit_truncation() {
1014        let mut tg = TestGraph::new();
1015        let a = tg.add_node("a");
1016        let b = tg.add_node("b");
1017        let c = tg.add_node("c");
1018        let d = tg.add_node("d");
1019        tg.add_call_edge(a, b);
1020        tg.add_call_edge(a, c);
1021        tg.add_call_edge(a, d);
1022
1023        let snapshot = tg.snapshot();
1024        let config = TraversalConfig {
1025            direction: TraversalDirection::Outgoing,
1026            edge_filter: EdgeFilter::calls_only(),
1027            limits: TraversalLimits {
1028                max_depth: 5,
1029                max_nodes: Some(2),
1030                max_edges: None,
1031                max_paths: None,
1032            },
1033        };
1034
1035        let result = traverse(&snapshot, &[a], &config, None);
1036
1037        assert_eq!(
1038            result.metadata.truncation,
1039            Some(TruncationReason::NodeLimit)
1040        );
1041    }
1042
1043    #[test]
1044    fn empty_seeds() {
1045        let tg = TestGraph::new();
1046        let snapshot = tg.snapshot();
1047        let result = traverse(&snapshot, &[], &calls_config(3), None);
1048
1049        assert!(result.nodes.is_empty());
1050        assert!(result.edges.is_empty());
1051        assert!(result.metadata.truncation.is_none());
1052        assert_eq!(result.metadata.seed_count, 0);
1053    }
1054
1055    #[test]
1056    fn incoming_bfs() {
1057        let mut tg = TestGraph::new();
1058        let a = tg.add_node("a");
1059        let b = tg.add_node("b");
1060        let c = tg.add_node("c");
1061        tg.add_call_edge(a, b);
1062        tg.add_call_edge(c, b);
1063
1064        let snapshot = tg.snapshot();
1065        let config = TraversalConfig {
1066            direction: TraversalDirection::Incoming,
1067            edge_filter: EdgeFilter::calls_only(),
1068            limits: TraversalLimits {
1069                max_depth: 3,
1070                max_nodes: None,
1071                max_edges: None,
1072                max_paths: None,
1073            },
1074        };
1075
1076        let result = traverse(&snapshot, &[b], &config, None);
1077
1078        // b's incoming edges are from a and c
1079        assert_eq!(result.nodes.len(), 3);
1080    }
1081
1082    #[test]
1083    fn bidirectional_bfs() {
1084        let mut tg = TestGraph::new();
1085        let a = tg.add_node("a");
1086        let b = tg.add_node("b");
1087        let c = tg.add_node("c");
1088        tg.add_call_edge(a, b);
1089        tg.add_call_edge(b, c);
1090
1091        let snapshot = tg.snapshot();
1092        let config = TraversalConfig {
1093            direction: TraversalDirection::Both,
1094            edge_filter: EdgeFilter::calls_only(),
1095            limits: TraversalLimits {
1096                max_depth: 3,
1097                max_nodes: None,
1098                max_edges: None,
1099                max_paths: None,
1100            },
1101        };
1102
1103        // Start from b — should find a (incoming) and c (outgoing)
1104        let result = traverse(&snapshot, &[b], &config, None);
1105        assert_eq!(result.nodes.len(), 3);
1106    }
1107
1108    #[test]
1109    fn edge_filtering() {
1110        let mut tg = TestGraph::new();
1111        let a = tg.add_node("a");
1112        let b = tg.add_node("b");
1113        let c = tg.add_node("c");
1114        tg.add_call_edge(a, b);
1115        tg.add_edge(
1116            a,
1117            c,
1118            EdgeKind::Imports {
1119                alias: None,
1120                is_wildcard: false,
1121            },
1122        );
1123
1124        let snapshot = tg.snapshot();
1125
1126        // calls_only should not include the import edge
1127        let result = traverse(&snapshot, &[a], &calls_config(3), None);
1128        assert_eq!(result.nodes.len(), 2); // a and b only
1129
1130        // calls_and_imports should include both
1131        let config = TraversalConfig {
1132            direction: TraversalDirection::Outgoing,
1133            edge_filter: EdgeFilter::calls_and_imports(),
1134            limits: TraversalLimits {
1135                max_depth: 3,
1136                max_nodes: None,
1137                max_edges: None,
1138                max_paths: None,
1139            },
1140        };
1141        let result = traverse(&snapshot, &[a], &config, None);
1142        assert_eq!(result.nodes.len(), 3); // a, b, and c
1143    }
1144
1145    #[test]
1146    fn cycle_handling_standard() {
1147        let mut tg = TestGraph::new();
1148        let a = tg.add_node("a");
1149        let b = tg.add_node("b");
1150        tg.add_call_edge(a, b);
1151        tg.add_call_edge(b, a);
1152
1153        let snapshot = tg.snapshot();
1154        let result = traverse(&snapshot, &[a], &calls_config(10), None);
1155
1156        // Global visited set prevents infinite loop
1157        assert_eq!(result.nodes.len(), 2);
1158    }
1159
1160    #[test]
1161    fn edge_limit_truncation() {
1162        let mut tg = TestGraph::new();
1163        let a = tg.add_node("a");
1164        let b = tg.add_node("b");
1165        let c = tg.add_node("c");
1166        let d = tg.add_node("d");
1167        tg.add_call_edge(a, b);
1168        tg.add_call_edge(a, c);
1169        tg.add_call_edge(a, d);
1170
1171        let snapshot = tg.snapshot();
1172        let config = TraversalConfig {
1173            direction: TraversalDirection::Outgoing,
1174            edge_filter: EdgeFilter::calls_only(),
1175            limits: TraversalLimits {
1176                max_depth: 5,
1177                max_nodes: None,
1178                max_edges: Some(2),
1179                max_paths: None,
1180            },
1181        };
1182
1183        let result = traverse(&snapshot, &[a], &config, None);
1184        assert_eq!(
1185            result.metadata.truncation,
1186            Some(TruncationReason::EdgeLimit)
1187        );
1188    }
1189
1190    #[test]
1191    fn index_invariants_hold() {
1192        let mut tg = TestGraph::new();
1193        let a = tg.add_node("a");
1194        let b = tg.add_node("b");
1195        let c = tg.add_node("c");
1196        tg.add_call_edge(a, b);
1197        tg.add_call_edge(b, c);
1198
1199        let snapshot = tg.snapshot();
1200        let result = traverse(&snapshot, &[a], &calls_config(5), None);
1201
1202        // Every edge index must be < nodes.len()
1203        for edge in &result.edges {
1204            assert!(edge.source_idx < result.nodes.len());
1205            assert!(edge.target_idx < result.nodes.len());
1206        }
1207
1208        // Metadata is consistent
1209        assert_eq!(result.metadata.total_nodes, result.nodes.len());
1210        assert_eq!(result.metadata.total_edges, result.edges.len());
1211    }
1212
1213    #[test]
1214    fn all_filter_includes_structural() {
1215        let mut tg = TestGraph::new();
1216        let a = tg.add_node("a");
1217        let b = tg.add_node("b");
1218        tg.add_edge(a, b, EdgeKind::Defines);
1219
1220        let snapshot = tg.snapshot();
1221        let config = TraversalConfig {
1222            direction: TraversalDirection::Outgoing,
1223            edge_filter: EdgeFilter::all(),
1224            limits: TraversalLimits {
1225                max_depth: 3,
1226                max_nodes: None,
1227                max_edges: None,
1228                max_paths: None,
1229            },
1230        };
1231        let result = traverse(&snapshot, &[a], &config, None);
1232        assert_eq!(result.nodes.len(), 2);
1233        assert_eq!(result.edges.len(), 1);
1234        assert_eq!(result.edges[0].classification, EdgeClassification::Defines);
1235    }
1236
1237    // ──────────────────── Path enumeration tests ────────────────────
1238
1239    #[test]
1240    fn path_enumeration_finds_path() {
1241        let mut tg = TestGraph::new();
1242        let a = tg.add_node("pa");
1243        let b = tg.add_node("pb");
1244        let c = tg.add_node("pc");
1245        tg.add_call_edge(a, b);
1246        tg.add_call_edge(b, c);
1247
1248        let snapshot = tg.snapshot();
1249        let config = TraversalConfig {
1250            direction: TraversalDirection::Outgoing,
1251            edge_filter: EdgeFilter::calls_only(),
1252            limits: TraversalLimits {
1253                max_depth: 5,
1254                max_nodes: None,
1255                max_edges: None,
1256                max_paths: Some(10),
1257            },
1258        };
1259
1260        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1261        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1262
1263        assert!(result.paths.is_some());
1264        let paths = result.paths.as_ref().unwrap();
1265        assert_eq!(paths.len(), 1, "expected 1 path from a→c");
1266        // Path should have 3 nodes: a → b → c
1267        assert_eq!(paths[0].len(), 3);
1268    }
1269
1270    #[test]
1271    fn path_local_allows_shared_nodes_in_diamond() {
1272        // Diamond graph: a→b, a→c, b→d, c→d
1273        let mut tg = TestGraph::new();
1274        let a = tg.add_node("da");
1275        let b = tg.add_node("db");
1276        let c = tg.add_node("dc");
1277        let d = tg.add_node("dd");
1278        tg.add_call_edge(a, b);
1279        tg.add_call_edge(a, c);
1280        tg.add_call_edge(b, d);
1281        tg.add_call_edge(c, d);
1282
1283        let snapshot = tg.snapshot();
1284        let config = TraversalConfig {
1285            direction: TraversalDirection::Outgoing,
1286            edge_filter: EdgeFilter::calls_only(),
1287            limits: TraversalLimits {
1288                max_depth: 5,
1289                max_nodes: None,
1290                max_edges: None,
1291                max_paths: Some(10),
1292            },
1293        };
1294
1295        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1296        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1297
1298        let paths = result.paths.as_ref().unwrap();
1299        // Two paths: a→b→d and a→c→d
1300        assert_eq!(paths.len(), 2, "expected 2 paths in diamond, got {paths:?}");
1301    }
1302
1303    #[test]
1304    fn path_enumeration_handles_cycles() {
1305        // a→b→c→a (cycle), looking for path to c
1306        let mut tg = TestGraph::new();
1307        let a = tg.add_node("ca");
1308        let b = tg.add_node("cb");
1309        let c = tg.add_node("cc");
1310        tg.add_call_edge(a, b);
1311        tg.add_call_edge(b, c);
1312        tg.add_call_edge(c, a); // cycle back
1313
1314        let snapshot = tg.snapshot();
1315        let config = TraversalConfig {
1316            direction: TraversalDirection::Outgoing,
1317            edge_filter: EdgeFilter::calls_only(),
1318            limits: TraversalLimits {
1319                max_depth: 10,
1320                max_nodes: None,
1321                max_edges: None,
1322                max_paths: Some(10),
1323            },
1324        };
1325
1326        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1327        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1328
1329        let paths = result.paths.as_ref().unwrap();
1330        // Should find a→b→c without infinite loop
1331        assert_eq!(paths.len(), 1);
1332        assert_eq!(paths[0].len(), 3);
1333    }
1334
1335    #[test]
1336    fn path_enumeration_no_path() {
1337        // a→b, c is disconnected, looking for path from a to c
1338        let mut tg = TestGraph::new();
1339        let a = tg.add_node("na");
1340        let b = tg.add_node("nb");
1341        let c = tg.add_node("nc");
1342        tg.add_call_edge(a, b);
1343
1344        let snapshot = tg.snapshot();
1345        let config = TraversalConfig {
1346            direction: TraversalDirection::Outgoing,
1347            edge_filter: EdgeFilter::calls_only(),
1348            limits: TraversalLimits {
1349                max_depth: 5,
1350                max_nodes: None,
1351                max_edges: None,
1352                max_paths: Some(10),
1353            },
1354        };
1355
1356        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1357        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1358
1359        let paths = result.paths.as_ref().unwrap();
1360        assert!(paths.is_empty(), "expected no paths, got {paths:?}");
1361    }
1362
1363    #[test]
1364    fn path_limit_truncation() {
1365        // Create a graph with many paths: a→b1, a→b2, a→b3, b1→c, b2→c, b3→c
1366        let mut tg = TestGraph::new();
1367        let a = tg.add_node("la");
1368        let b1 = tg.add_node("lb1");
1369        let b2 = tg.add_node("lb2");
1370        let b3 = tg.add_node("lb3");
1371        let c = tg.add_node("lc");
1372        tg.add_call_edge(a, b1);
1373        tg.add_call_edge(a, b2);
1374        tg.add_call_edge(a, b3);
1375        tg.add_call_edge(b1, c);
1376        tg.add_call_edge(b2, c);
1377        tg.add_call_edge(b3, c);
1378
1379        let snapshot = tg.snapshot();
1380        let config = TraversalConfig {
1381            direction: TraversalDirection::Outgoing,
1382            edge_filter: EdgeFilter::calls_only(),
1383            limits: TraversalLimits {
1384                max_depth: 5,
1385                max_nodes: None,
1386                max_edges: None,
1387                max_paths: Some(2),
1388            },
1389        };
1390
1391        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1392        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1393
1394        let paths = result.paths.as_ref().unwrap();
1395        assert_eq!(paths.len(), 2, "expected exactly 2 paths (limited)");
1396        assert_eq!(
1397            result.metadata.truncation,
1398            Some(TruncationReason::PathLimit)
1399        );
1400    }
1401
1402    #[test]
1403    fn path_bfs_node_limit_truncation() {
1404        // a→b→c→d, path to d, but max_nodes=2
1405        let mut tg = TestGraph::new();
1406        let a = tg.add_node("pna");
1407        let b = tg.add_node("pnb");
1408        let c = tg.add_node("pnc");
1409        let d = tg.add_node("pnd");
1410        tg.add_call_edge(a, b);
1411        tg.add_call_edge(b, c);
1412        tg.add_call_edge(c, d);
1413
1414        let snapshot = tg.snapshot();
1415        let config = TraversalConfig {
1416            direction: TraversalDirection::Outgoing,
1417            edge_filter: EdgeFilter::calls_only(),
1418            limits: TraversalLimits {
1419                max_depth: 10,
1420                max_nodes: Some(2),
1421                max_edges: None,
1422                max_paths: Some(10),
1423            },
1424        };
1425
1426        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1427        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1428
1429        assert!(
1430            result.nodes.len() <= 2,
1431            "node limit violated: {} nodes",
1432            result.nodes.len()
1433        );
1434        assert_eq!(
1435            result.metadata.truncation,
1436            Some(TruncationReason::NodeLimit)
1437        );
1438    }
1439
1440    #[test]
1441    fn path_bfs_edge_limit_truncation() {
1442        // a→b→c→d, path to d, but max_edges=1
1443        let mut tg = TestGraph::new();
1444        let a = tg.add_node("pea");
1445        let b = tg.add_node("peb");
1446        let c = tg.add_node("pec");
1447        let d = tg.add_node("ped");
1448        tg.add_call_edge(a, b);
1449        tg.add_call_edge(b, c);
1450        tg.add_call_edge(c, d);
1451
1452        let snapshot = tg.snapshot();
1453        let config = TraversalConfig {
1454            direction: TraversalDirection::Outgoing,
1455            edge_filter: EdgeFilter::calls_only(),
1456            limits: TraversalLimits {
1457                max_depth: 10,
1458                max_nodes: None,
1459                max_edges: Some(1),
1460                max_paths: Some(10),
1461            },
1462        };
1463
1464        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1465        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1466
1467        assert_eq!(
1468            result.metadata.truncation,
1469            Some(TruncationReason::EdgeLimit)
1470        );
1471    }
1472
1473    #[test]
1474    fn path_bfs_leaf_enumeration_no_target() {
1475        // a→b→c (leaf), a→d (leaf) — enumerate paths to all leaves
1476        let mut tg = TestGraph::new();
1477        let a = tg.add_node("la");
1478        let b = tg.add_node("lb");
1479        let c = tg.add_node("lc");
1480        let d = tg.add_node("ld");
1481        tg.add_call_edge(a, b);
1482        tg.add_call_edge(b, c);
1483        tg.add_call_edge(a, d);
1484
1485        let snapshot = tg.snapshot();
1486        let config = TraversalConfig {
1487            direction: TraversalDirection::Outgoing,
1488            edge_filter: EdgeFilter::calls_only(),
1489            limits: TraversalLimits {
1490                max_depth: 10,
1491                max_nodes: None,
1492                max_edges: None,
1493                max_paths: Some(10),
1494            },
1495        };
1496
1497        // No target — should enumerate paths to leaves
1498        let mut strategy = LeafPathStrategy;
1499        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1500
1501        let paths = result.paths.as_ref().unwrap();
1502        // Two leaf paths: a→b→c and a→d
1503        assert!(
1504            paths.len() >= 2,
1505            "expected at least 2 leaf paths, got {}",
1506            paths.len()
1507        );
1508    }
1509
1510    /// Strategy that enumerates paths to all leaves (no specific target).
1511    struct LeafPathStrategy;
1512
1513    impl TraversalStrategy for LeafPathStrategy {
1514        fn frontier_mode(&self) -> FrontierMode {
1515            FrontierMode::PathEnumeration
1516        }
1517
1518        fn visited_policy(&self) -> VisitedPolicy {
1519            VisitedPolicy::PathLocal
1520        }
1521
1522        fn path_target(&self) -> Option<NodeId> {
1523            None
1524        }
1525    }
1526
1527    #[test]
1528    fn is_followable_edge_confidence_filtering() {
1529        // High confidence edges pass any threshold
1530        let calls = EdgeKind::Calls {
1531            argument_count: 0,
1532            is_async: false,
1533        };
1534        assert!(is_followable_edge(&calls, 0.0));
1535        assert!(is_followable_edge(&calls, 1.0));
1536
1537        // FFI call has 0.6 confidence
1538        let ffi = EdgeKind::FfiCall {
1539            convention: crate::graph::unified::edge::kind::FfiConvention::C,
1540        };
1541        assert!(is_followable_edge(&ffi, 0.5));
1542        assert!(is_followable_edge(&ffi, 0.6));
1543        assert!(!is_followable_edge(&ffi, 0.7));
1544
1545        // Defines has 0.3 confidence
1546        assert!(is_followable_edge(&EdgeKind::Defines, 0.3));
1547        assert!(!is_followable_edge(&EdgeKind::Defines, 0.4));
1548    }
1549}