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; complex path-tracking BFS state machine
580fn run_path_bfs(
581    snapshot: &GraphSnapshot,
582    seeds: &[NodeId],
583    config: &TraversalConfig,
584    strategy: &mut dyn TraversalStrategy,
585) -> TraversalResult {
586    let target = strategy.path_target();
587    let mut collected_paths: Vec<Vec<NodeId>> = Vec::new();
588    let mut discovered_order: Vec<NodeId> = Vec::new();
589    let mut seen: HashSet<NodeId> = HashSet::new();
590    let mut raw_edges: Vec<RawEdge> = Vec::new();
591    let mut truncation: Option<TruncationReason> = None;
592    let mut max_depth_reached = false;
593    let mut nodes_visited: usize = 0;
594
595    // Queue: (current_node, path_so_far, depth)
596    let mut queue: VecDeque<(NodeId, Vec<NodeId>, u32)> = VecDeque::new();
597
598    for &seed in seeds {
599        queue.push_back((seed, vec![seed], 0));
600        if seen.insert(seed) {
601            discovered_order.push(seed);
602        }
603    }
604
605    let use_global_visited = strategy.visited_policy() == VisitedPolicy::Global;
606    let mut global_visited: HashSet<NodeId> = if use_global_visited {
607        seeds.iter().copied().collect()
608    } else {
609        HashSet::new()
610    };
611
612    'bfs: while let Some((current, path, depth)) = queue.pop_front() {
613        nodes_visited += 1;
614
615        // Check if we reached the target
616        if let Some(t) = target
617            && current == t
618            && path.len() > 1
619        {
620            // Record nodes along the path in discovery order
621            for &node in &path {
622                if seen.insert(node) {
623                    discovered_order.push(node);
624                }
625            }
626
627            let control = strategy.on_path_complete(&path);
628            collected_paths.push(path);
629
630            // Check path limit
631            if let Some(max_paths) = config.limits.max_paths
632                && collected_paths.len() >= max_paths
633            {
634                truncation = Some(TruncationReason::PathLimit);
635                break 'bfs;
636            }
637
638            if control.is_break() {
639                break 'bfs;
640            }
641            continue;
642        }
643
644        if depth >= config.limits.max_depth {
645            max_depth_reached = true;
646            continue;
647        }
648
649        // Check node limit
650        if let Some(max_nodes) = config.limits.max_nodes
651            && discovered_order.len() >= max_nodes
652        {
653            truncation = Some(TruncationReason::NodeLimit);
654            break;
655        }
656
657        let edges = collect_edges(snapshot, current, config.direction);
658
659        // If no target specified, leaf nodes (no followable outgoing edges) complete a path
660        let mut has_followable_successor = false;
661
662        for edge_ref in &edges {
663            // Check edge limit
664            if let Some(max_edges) = config.limits.max_edges
665                && raw_edges.len() >= max_edges
666            {
667                truncation = Some(TruncationReason::EdgeLimit);
668                break 'bfs;
669            }
670
671            let classification = EdgeClassification::from(&edge_ref.kind);
672            if !config.edge_filter.accepts(&classification) {
673                continue;
674            }
675
676            if !strategy.accept_edge(edge_ref, depth) {
677                continue;
678            }
679
680            let next = neighbor_of(edge_ref, current);
681
682            // Cycle detection: path-local or global
683            if use_global_visited {
684                if !global_visited.insert(next) {
685                    continue;
686                }
687            } else if path.contains(&next) {
688                continue;
689            }
690
691            if !strategy.should_enqueue(next, current, &edge_ref.kind, depth + 1) {
692                continue;
693            }
694
695            has_followable_successor = true;
696
697            raw_edges.push(RawEdge {
698                source: edge_ref.source,
699                target: edge_ref.target,
700                kind: edge_ref.kind.clone(),
701                depth: depth + 1,
702            });
703
704            // Enforce node limit before adding
705            if let Some(max_nodes) = config.limits.max_nodes
706                && discovered_order.len() >= max_nodes
707            {
708                truncation = Some(TruncationReason::NodeLimit);
709                break 'bfs;
710            }
711
712            if seen.insert(next) {
713                discovered_order.push(next);
714            }
715
716            let mut new_path = path.clone();
717            new_path.push(next);
718            queue.push_back((next, new_path, depth + 1));
719        }
720
721        // Leaf enumeration: if no target specified and node is a leaf, record path
722        if target.is_none() && !has_followable_successor && path.len() > 1 {
723            for &node in &path {
724                if seen.insert(node) {
725                    discovered_order.push(node);
726                }
727            }
728
729            let control = strategy.on_path_complete(&path);
730            collected_paths.push(path);
731
732            if let Some(max_paths) = config.limits.max_paths
733                && collected_paths.len() >= max_paths
734            {
735                truncation = Some(TruncationReason::PathLimit);
736                break 'bfs;
737            }
738
739            if control.is_break() {
740                break 'bfs;
741            }
742        }
743    }
744
745    let paths_for_result = Some(collected_paths);
746
747    materialize_result(
748        snapshot,
749        &discovered_order,
750        &raw_edges,
751        paths_for_result,
752        truncation,
753        max_depth_reached,
754        seeds.len(),
755        nodes_visited,
756    )
757}
758
759// ──────────────────── Shared helpers ────────────────────
760
761/// Collect edges for a node in the given direction.
762fn collect_edges(
763    snapshot: &GraphSnapshot,
764    node: NodeId,
765    direction: TraversalDirection,
766) -> Vec<StoreEdgeRef> {
767    match direction {
768        TraversalDirection::Outgoing => snapshot.edges().edges_from(node),
769        TraversalDirection::Incoming => snapshot.edges().edges_to(node),
770        TraversalDirection::Both => {
771            let mut edges = snapshot.edges().edges_from(node);
772            edges.extend(snapshot.edges().edges_to(node));
773            edges
774        }
775    }
776}
777
778/// Given an edge and the current node, return the neighbor to traverse to.
779fn neighbor_of(edge_ref: &StoreEdgeRef, current: NodeId) -> NodeId {
780    if edge_ref.source == current {
781        edge_ref.target
782    } else {
783        edge_ref.source
784    }
785}
786
787/// Materialize collected BFS results into a `TraversalResult`.
788///
789/// Converts discovered `NodeId`s into `MaterializedNode`s, builds the index map,
790/// converts raw edges to `MaterializedEdge`s, and optionally converts paths.
791#[allow(clippy::too_many_arguments)]
792fn materialize_result(
793    snapshot: &GraphSnapshot,
794    discovered_order: &[NodeId],
795    raw_edges: &[RawEdge],
796    raw_paths: Option<Vec<Vec<NodeId>>>,
797    truncation: Option<TruncationReason>,
798    max_depth_reached: bool,
799    seed_count: usize,
800    nodes_visited: usize,
801) -> TraversalResult {
802    // Materialize nodes and build index map
803    let mut nodes = Vec::with_capacity(discovered_order.len());
804    let mut node_index: HashMap<NodeId, usize> = HashMap::with_capacity(discovered_order.len());
805
806    for &node_id in discovered_order {
807        if node_index.contains_key(&node_id) {
808            continue;
809        }
810        if let Some(materialized) = materialize_node(snapshot, node_id) {
811            let idx = nodes.len();
812            node_index.insert(node_id, idx);
813            nodes.push(materialized);
814        }
815    }
816
817    // Convert raw edges to MaterializedEdge, dropping any with missing indices
818    let mut edges = Vec::with_capacity(raw_edges.len());
819    for raw in raw_edges {
820        if let (Some(&source_idx), Some(&target_idx)) =
821            (node_index.get(&raw.source), node_index.get(&raw.target))
822        {
823            edges.push(MaterializedEdge {
824                source_idx,
825                target_idx,
826                classification: EdgeClassification::from(&raw.kind),
827                raw_kind: raw.kind.clone(),
828                depth: raw.depth,
829            });
830        }
831    }
832
833    // Deduplicate edges (same source_idx, target_idx, classification)
834    edges.dedup_by(|a, b| {
835        a.source_idx == b.source_idx
836            && a.target_idx == b.target_idx
837            && a.classification == b.classification
838    });
839
840    // Convert paths
841    let paths = raw_paths.map(|path_list| {
842        path_list
843            .iter()
844            .filter_map(|path| {
845                let converted: Vec<usize> = path
846                    .iter()
847                    .filter_map(|node_id| node_index.get(node_id).copied())
848                    .collect();
849                // Drop paths where any node couldn't be materialized
850                if converted.len() == path.len() {
851                    Some(converted)
852                } else {
853                    None
854                }
855            })
856            .collect()
857    });
858
859    TraversalResult {
860        metadata: TraversalMetadata {
861            truncation,
862            max_depth_reached,
863            seed_count,
864            nodes_visited,
865            total_nodes: nodes.len(),
866            total_edges: edges.len(),
867        },
868        nodes,
869        edges,
870        paths,
871    }
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use crate::graph::node::Language;
878    use crate::graph::unified::concurrent::CodeGraph;
879    use crate::graph::unified::node::kind::NodeKind;
880    use crate::graph::unified::storage::arena::NodeEntry;
881
882    use crate::graph::unified::file::FileId;
883
884    /// Helper to create a test graph with nodes and edges.
885    struct TestGraph {
886        graph: CodeGraph,
887        file_id: Option<FileId>,
888    }
889
890    impl TestGraph {
891        fn new() -> Self {
892            Self {
893                graph: CodeGraph::new(),
894                file_id: None,
895            }
896        }
897
898        fn ensure_file_id(&mut self) -> FileId {
899            if let Some(fid) = self.file_id {
900                return fid;
901            }
902            let file_path = std::path::PathBuf::from("/kernel-tests/test.rs");
903            let fid = self
904                .graph
905                .files_mut()
906                .register_with_language(&file_path, Some(Language::Rust))
907                .unwrap();
908            self.file_id = Some(fid);
909            fid
910        }
911
912        fn add_node(&mut self, name: &str) -> NodeId {
913            self.add_node_with_kind(name, NodeKind::Function)
914        }
915
916        fn add_node_with_kind(&mut self, name: &str, kind: NodeKind) -> NodeId {
917            let file_id = self.ensure_file_id();
918            let name_id = self.graph.strings_mut().intern(name).unwrap();
919            let qn_id = self
920                .graph
921                .strings_mut()
922                .intern(&format!("test::{name}"))
923                .unwrap();
924
925            let entry = NodeEntry::new(kind, name_id, file_id)
926                .with_qualified_name(qn_id)
927                .with_location(1, 0, 10, 0);
928
929            let node_id = self.graph.nodes_mut().alloc(entry).unwrap();
930            self.graph
931                .indices_mut()
932                .add(node_id, kind, name_id, Some(qn_id), file_id);
933            node_id
934        }
935
936        fn add_call_edge(&mut self, source: NodeId, target: NodeId) {
937            let file_id = self.ensure_file_id();
938            self.graph.edges_mut().add_edge(
939                source,
940                target,
941                EdgeKind::Calls {
942                    argument_count: 0,
943                    is_async: false,
944                },
945                file_id,
946            );
947        }
948
949        fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) {
950            let file_id = self.ensure_file_id();
951            self.graph
952                .edges_mut()
953                .add_edge(source, target, kind, file_id);
954        }
955
956        fn snapshot(&self) -> GraphSnapshot {
957            self.graph.snapshot()
958        }
959    }
960
961    fn calls_config(depth: u32) -> TraversalConfig {
962        TraversalConfig {
963            direction: TraversalDirection::Outgoing,
964            edge_filter: EdgeFilter::calls_only(),
965            limits: TraversalLimits {
966                max_depth: depth,
967                max_nodes: None,
968                max_edges: None,
969                max_paths: None,
970            },
971        }
972    }
973
974    #[test]
975    fn standard_outgoing_bfs() {
976        let mut tg = TestGraph::new();
977        let a = tg.add_node("a");
978        let b = tg.add_node("b");
979        let c = tg.add_node("c");
980        tg.add_call_edge(a, b);
981        tg.add_call_edge(b, c);
982
983        let snapshot = tg.snapshot();
984        let result = traverse(&snapshot, &[a], &calls_config(3), None);
985
986        assert_eq!(result.nodes.len(), 3);
987        assert_eq!(result.edges.len(), 2);
988        assert!(result.metadata.truncation.is_none());
989        // First node should be the seed
990        assert_eq!(result.nodes[0].node_id, a);
991    }
992
993    #[test]
994    fn depth_limit() {
995        let mut tg = TestGraph::new();
996        let a = tg.add_node("a");
997        let b = tg.add_node("b");
998        let c = tg.add_node("c");
999        tg.add_call_edge(a, b);
1000        tg.add_call_edge(b, c);
1001
1002        let snapshot = tg.snapshot();
1003        let result = traverse(&snapshot, &[a], &calls_config(1), None);
1004
1005        // Depth 1: should discover a and b, but not c (b→c is at depth 2)
1006        assert_eq!(result.nodes.len(), 2);
1007        assert_eq!(result.edges.len(), 1);
1008        assert!(result.metadata.max_depth_reached);
1009    }
1010
1011    #[test]
1012    fn node_limit_truncation() {
1013        let mut tg = TestGraph::new();
1014        let a = tg.add_node("a");
1015        let b = tg.add_node("b");
1016        let c = tg.add_node("c");
1017        let d = tg.add_node("d");
1018        tg.add_call_edge(a, b);
1019        tg.add_call_edge(a, c);
1020        tg.add_call_edge(a, d);
1021
1022        let snapshot = tg.snapshot();
1023        let config = TraversalConfig {
1024            direction: TraversalDirection::Outgoing,
1025            edge_filter: EdgeFilter::calls_only(),
1026            limits: TraversalLimits {
1027                max_depth: 5,
1028                max_nodes: Some(2),
1029                max_edges: None,
1030                max_paths: None,
1031            },
1032        };
1033
1034        let result = traverse(&snapshot, &[a], &config, None);
1035
1036        assert_eq!(
1037            result.metadata.truncation,
1038            Some(TruncationReason::NodeLimit)
1039        );
1040    }
1041
1042    #[test]
1043    fn empty_seeds() {
1044        let tg = TestGraph::new();
1045        let snapshot = tg.snapshot();
1046        let result = traverse(&snapshot, &[], &calls_config(3), None);
1047
1048        assert!(result.nodes.is_empty());
1049        assert!(result.edges.is_empty());
1050        assert!(result.metadata.truncation.is_none());
1051        assert_eq!(result.metadata.seed_count, 0);
1052    }
1053
1054    #[test]
1055    fn incoming_bfs() {
1056        let mut tg = TestGraph::new();
1057        let a = tg.add_node("a");
1058        let b = tg.add_node("b");
1059        let c = tg.add_node("c");
1060        tg.add_call_edge(a, b);
1061        tg.add_call_edge(c, b);
1062
1063        let snapshot = tg.snapshot();
1064        let config = TraversalConfig {
1065            direction: TraversalDirection::Incoming,
1066            edge_filter: EdgeFilter::calls_only(),
1067            limits: TraversalLimits {
1068                max_depth: 3,
1069                max_nodes: None,
1070                max_edges: None,
1071                max_paths: None,
1072            },
1073        };
1074
1075        let result = traverse(&snapshot, &[b], &config, None);
1076
1077        // b's incoming edges are from a and c
1078        assert_eq!(result.nodes.len(), 3);
1079    }
1080
1081    #[test]
1082    fn bidirectional_bfs() {
1083        let mut tg = TestGraph::new();
1084        let a = tg.add_node("a");
1085        let b = tg.add_node("b");
1086        let c = tg.add_node("c");
1087        tg.add_call_edge(a, b);
1088        tg.add_call_edge(b, c);
1089
1090        let snapshot = tg.snapshot();
1091        let config = TraversalConfig {
1092            direction: TraversalDirection::Both,
1093            edge_filter: EdgeFilter::calls_only(),
1094            limits: TraversalLimits {
1095                max_depth: 3,
1096                max_nodes: None,
1097                max_edges: None,
1098                max_paths: None,
1099            },
1100        };
1101
1102        // Start from b — should find a (incoming) and c (outgoing)
1103        let result = traverse(&snapshot, &[b], &config, None);
1104        assert_eq!(result.nodes.len(), 3);
1105    }
1106
1107    #[test]
1108    fn edge_filtering() {
1109        let mut tg = TestGraph::new();
1110        let a = tg.add_node("a");
1111        let b = tg.add_node("b");
1112        let c = tg.add_node("c");
1113        tg.add_call_edge(a, b);
1114        tg.add_edge(
1115            a,
1116            c,
1117            EdgeKind::Imports {
1118                alias: None,
1119                is_wildcard: false,
1120            },
1121        );
1122
1123        let snapshot = tg.snapshot();
1124
1125        // calls_only should not include the import edge
1126        let result = traverse(&snapshot, &[a], &calls_config(3), None);
1127        assert_eq!(result.nodes.len(), 2); // a and b only
1128
1129        // calls_and_imports should include both
1130        let config = TraversalConfig {
1131            direction: TraversalDirection::Outgoing,
1132            edge_filter: EdgeFilter::calls_and_imports(),
1133            limits: TraversalLimits {
1134                max_depth: 3,
1135                max_nodes: None,
1136                max_edges: None,
1137                max_paths: None,
1138            },
1139        };
1140        let result = traverse(&snapshot, &[a], &config, None);
1141        assert_eq!(result.nodes.len(), 3); // a, b, and c
1142    }
1143
1144    #[test]
1145    fn cycle_handling_standard() {
1146        let mut tg = TestGraph::new();
1147        let a = tg.add_node("a");
1148        let b = tg.add_node("b");
1149        tg.add_call_edge(a, b);
1150        tg.add_call_edge(b, a);
1151
1152        let snapshot = tg.snapshot();
1153        let result = traverse(&snapshot, &[a], &calls_config(10), None);
1154
1155        // Global visited set prevents infinite loop
1156        assert_eq!(result.nodes.len(), 2);
1157    }
1158
1159    #[test]
1160    fn edge_limit_truncation() {
1161        let mut tg = TestGraph::new();
1162        let a = tg.add_node("a");
1163        let b = tg.add_node("b");
1164        let c = tg.add_node("c");
1165        let d = tg.add_node("d");
1166        tg.add_call_edge(a, b);
1167        tg.add_call_edge(a, c);
1168        tg.add_call_edge(a, d);
1169
1170        let snapshot = tg.snapshot();
1171        let config = TraversalConfig {
1172            direction: TraversalDirection::Outgoing,
1173            edge_filter: EdgeFilter::calls_only(),
1174            limits: TraversalLimits {
1175                max_depth: 5,
1176                max_nodes: None,
1177                max_edges: Some(2),
1178                max_paths: None,
1179            },
1180        };
1181
1182        let result = traverse(&snapshot, &[a], &config, None);
1183        assert_eq!(
1184            result.metadata.truncation,
1185            Some(TruncationReason::EdgeLimit)
1186        );
1187    }
1188
1189    #[test]
1190    fn index_invariants_hold() {
1191        let mut tg = TestGraph::new();
1192        let a = tg.add_node("a");
1193        let b = tg.add_node("b");
1194        let c = tg.add_node("c");
1195        tg.add_call_edge(a, b);
1196        tg.add_call_edge(b, c);
1197
1198        let snapshot = tg.snapshot();
1199        let result = traverse(&snapshot, &[a], &calls_config(5), None);
1200
1201        // Every edge index must be < nodes.len()
1202        for edge in &result.edges {
1203            assert!(edge.source_idx < result.nodes.len());
1204            assert!(edge.target_idx < result.nodes.len());
1205        }
1206
1207        // Metadata is consistent
1208        assert_eq!(result.metadata.total_nodes, result.nodes.len());
1209        assert_eq!(result.metadata.total_edges, result.edges.len());
1210    }
1211
1212    #[test]
1213    fn all_filter_includes_structural() {
1214        let mut tg = TestGraph::new();
1215        let a = tg.add_node("a");
1216        let b = tg.add_node("b");
1217        tg.add_edge(a, b, EdgeKind::Defines);
1218
1219        let snapshot = tg.snapshot();
1220        let config = TraversalConfig {
1221            direction: TraversalDirection::Outgoing,
1222            edge_filter: EdgeFilter::all(),
1223            limits: TraversalLimits {
1224                max_depth: 3,
1225                max_nodes: None,
1226                max_edges: None,
1227                max_paths: None,
1228            },
1229        };
1230        let result = traverse(&snapshot, &[a], &config, None);
1231        assert_eq!(result.nodes.len(), 2);
1232        assert_eq!(result.edges.len(), 1);
1233        assert_eq!(result.edges[0].classification, EdgeClassification::Defines);
1234    }
1235
1236    // ──────────────────── Path enumeration tests ────────────────────
1237
1238    #[test]
1239    fn path_enumeration_finds_path() {
1240        let mut tg = TestGraph::new();
1241        let a = tg.add_node("pa");
1242        let b = tg.add_node("pb");
1243        let c = tg.add_node("pc");
1244        tg.add_call_edge(a, b);
1245        tg.add_call_edge(b, c);
1246
1247        let snapshot = tg.snapshot();
1248        let config = TraversalConfig {
1249            direction: TraversalDirection::Outgoing,
1250            edge_filter: EdgeFilter::calls_only(),
1251            limits: TraversalLimits {
1252                max_depth: 5,
1253                max_nodes: None,
1254                max_edges: None,
1255                max_paths: Some(10),
1256            },
1257        };
1258
1259        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1260        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1261
1262        assert!(result.paths.is_some());
1263        let paths = result.paths.as_ref().unwrap();
1264        assert_eq!(paths.len(), 1, "expected 1 path from a→c");
1265        // Path should have 3 nodes: a → b → c
1266        assert_eq!(paths[0].len(), 3);
1267    }
1268
1269    #[test]
1270    fn path_local_allows_shared_nodes_in_diamond() {
1271        // Diamond graph: a→b, a→c, b→d, c→d
1272        let mut tg = TestGraph::new();
1273        let a = tg.add_node("da");
1274        let b = tg.add_node("db");
1275        let c = tg.add_node("dc");
1276        let d = tg.add_node("dd");
1277        tg.add_call_edge(a, b);
1278        tg.add_call_edge(a, c);
1279        tg.add_call_edge(b, d);
1280        tg.add_call_edge(c, d);
1281
1282        let snapshot = tg.snapshot();
1283        let config = TraversalConfig {
1284            direction: TraversalDirection::Outgoing,
1285            edge_filter: EdgeFilter::calls_only(),
1286            limits: TraversalLimits {
1287                max_depth: 5,
1288                max_nodes: None,
1289                max_edges: None,
1290                max_paths: Some(10),
1291            },
1292        };
1293
1294        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1295        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1296
1297        let paths = result.paths.as_ref().unwrap();
1298        // Two paths: a→b→d and a→c→d
1299        assert_eq!(paths.len(), 2, "expected 2 paths in diamond, got {paths:?}");
1300    }
1301
1302    #[test]
1303    fn path_enumeration_handles_cycles() {
1304        // a→b→c→a (cycle), looking for path to c
1305        let mut tg = TestGraph::new();
1306        let a = tg.add_node("ca");
1307        let b = tg.add_node("cb");
1308        let c = tg.add_node("cc");
1309        tg.add_call_edge(a, b);
1310        tg.add_call_edge(b, c);
1311        tg.add_call_edge(c, a); // cycle back
1312
1313        let snapshot = tg.snapshot();
1314        let config = TraversalConfig {
1315            direction: TraversalDirection::Outgoing,
1316            edge_filter: EdgeFilter::calls_only(),
1317            limits: TraversalLimits {
1318                max_depth: 10,
1319                max_nodes: None,
1320                max_edges: None,
1321                max_paths: Some(10),
1322            },
1323        };
1324
1325        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1326        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1327
1328        let paths = result.paths.as_ref().unwrap();
1329        // Should find a→b→c without infinite loop
1330        assert_eq!(paths.len(), 1);
1331        assert_eq!(paths[0].len(), 3);
1332    }
1333
1334    #[test]
1335    fn path_enumeration_no_path() {
1336        // a→b, c is disconnected, looking for path from a to c
1337        let mut tg = TestGraph::new();
1338        let a = tg.add_node("na");
1339        let b = tg.add_node("nb");
1340        let c = tg.add_node("nc");
1341        tg.add_call_edge(a, b);
1342
1343        let snapshot = tg.snapshot();
1344        let config = TraversalConfig {
1345            direction: TraversalDirection::Outgoing,
1346            edge_filter: EdgeFilter::calls_only(),
1347            limits: TraversalLimits {
1348                max_depth: 5,
1349                max_nodes: None,
1350                max_edges: None,
1351                max_paths: Some(10),
1352            },
1353        };
1354
1355        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1356        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1357
1358        let paths = result.paths.as_ref().unwrap();
1359        assert!(paths.is_empty(), "expected no paths, got {paths:?}");
1360    }
1361
1362    #[test]
1363    fn path_limit_truncation() {
1364        // Create a graph with many paths: a→b1, a→b2, a→b3, b1→c, b2→c, b3→c
1365        let mut tg = TestGraph::new();
1366        let a = tg.add_node("la");
1367        let b1 = tg.add_node("lb1");
1368        let b2 = tg.add_node("lb2");
1369        let b3 = tg.add_node("lb3");
1370        let c = tg.add_node("lc");
1371        tg.add_call_edge(a, b1);
1372        tg.add_call_edge(a, b2);
1373        tg.add_call_edge(a, b3);
1374        tg.add_call_edge(b1, c);
1375        tg.add_call_edge(b2, c);
1376        tg.add_call_edge(b3, c);
1377
1378        let snapshot = tg.snapshot();
1379        let config = TraversalConfig {
1380            direction: TraversalDirection::Outgoing,
1381            edge_filter: EdgeFilter::calls_only(),
1382            limits: TraversalLimits {
1383                max_depth: 5,
1384                max_nodes: None,
1385                max_edges: None,
1386                max_paths: Some(2),
1387            },
1388        };
1389
1390        let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1391        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1392
1393        let paths = result.paths.as_ref().unwrap();
1394        assert_eq!(paths.len(), 2, "expected exactly 2 paths (limited)");
1395        assert_eq!(
1396            result.metadata.truncation,
1397            Some(TruncationReason::PathLimit)
1398        );
1399    }
1400
1401    #[test]
1402    fn path_bfs_node_limit_truncation() {
1403        // a→b→c→d, path to d, but max_nodes=2
1404        let mut tg = TestGraph::new();
1405        let a = tg.add_node("pna");
1406        let b = tg.add_node("pnb");
1407        let c = tg.add_node("pnc");
1408        let d = tg.add_node("pnd");
1409        tg.add_call_edge(a, b);
1410        tg.add_call_edge(b, c);
1411        tg.add_call_edge(c, d);
1412
1413        let snapshot = tg.snapshot();
1414        let config = TraversalConfig {
1415            direction: TraversalDirection::Outgoing,
1416            edge_filter: EdgeFilter::calls_only(),
1417            limits: TraversalLimits {
1418                max_depth: 10,
1419                max_nodes: Some(2),
1420                max_edges: None,
1421                max_paths: Some(10),
1422            },
1423        };
1424
1425        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1426        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1427
1428        assert!(
1429            result.nodes.len() <= 2,
1430            "node limit violated: {} nodes",
1431            result.nodes.len()
1432        );
1433        assert_eq!(
1434            result.metadata.truncation,
1435            Some(TruncationReason::NodeLimit)
1436        );
1437    }
1438
1439    #[test]
1440    fn path_bfs_edge_limit_truncation() {
1441        // a→b→c→d, path to d, but max_edges=1
1442        let mut tg = TestGraph::new();
1443        let a = tg.add_node("pea");
1444        let b = tg.add_node("peb");
1445        let c = tg.add_node("pec");
1446        let d = tg.add_node("ped");
1447        tg.add_call_edge(a, b);
1448        tg.add_call_edge(b, c);
1449        tg.add_call_edge(c, d);
1450
1451        let snapshot = tg.snapshot();
1452        let config = TraversalConfig {
1453            direction: TraversalDirection::Outgoing,
1454            edge_filter: EdgeFilter::calls_only(),
1455            limits: TraversalLimits {
1456                max_depth: 10,
1457                max_nodes: None,
1458                max_edges: Some(1),
1459                max_paths: Some(10),
1460            },
1461        };
1462
1463        let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1464        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1465
1466        assert_eq!(
1467            result.metadata.truncation,
1468            Some(TruncationReason::EdgeLimit)
1469        );
1470    }
1471
1472    #[test]
1473    fn path_bfs_leaf_enumeration_no_target() {
1474        // a→b→c (leaf), a→d (leaf) — enumerate paths to all leaves
1475        let mut tg = TestGraph::new();
1476        let a = tg.add_node("la");
1477        let b = tg.add_node("lb");
1478        let c = tg.add_node("lc");
1479        let d = tg.add_node("ld");
1480        tg.add_call_edge(a, b);
1481        tg.add_call_edge(b, c);
1482        tg.add_call_edge(a, d);
1483
1484        let snapshot = tg.snapshot();
1485        let config = TraversalConfig {
1486            direction: TraversalDirection::Outgoing,
1487            edge_filter: EdgeFilter::calls_only(),
1488            limits: TraversalLimits {
1489                max_depth: 10,
1490                max_nodes: None,
1491                max_edges: None,
1492                max_paths: Some(10),
1493            },
1494        };
1495
1496        // No target — should enumerate paths to leaves
1497        let mut strategy = LeafPathStrategy;
1498        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1499
1500        let paths = result.paths.as_ref().unwrap();
1501        // Two leaf paths: a→b→c and a→d
1502        assert!(
1503            paths.len() >= 2,
1504            "expected at least 2 leaf paths, got {}",
1505            paths.len()
1506        );
1507    }
1508
1509    /// Strategy that enumerates paths to all leaves (no specific target).
1510    struct LeafPathStrategy;
1511
1512    impl TraversalStrategy for LeafPathStrategy {
1513        fn frontier_mode(&self) -> FrontierMode {
1514            FrontierMode::PathEnumeration
1515        }
1516
1517        fn visited_policy(&self) -> VisitedPolicy {
1518            VisitedPolicy::PathLocal
1519        }
1520
1521        fn path_target(&self) -> Option<NodeId> {
1522            None
1523        }
1524    }
1525
1526    #[test]
1527    fn is_followable_edge_confidence_filtering() {
1528        // High confidence edges pass any threshold
1529        let calls = EdgeKind::Calls {
1530            argument_count: 0,
1531            is_async: false,
1532        };
1533        assert!(is_followable_edge(&calls, 0.0));
1534        assert!(is_followable_edge(&calls, 1.0));
1535
1536        // FFI call has 0.6 confidence
1537        let ffi = EdgeKind::FfiCall {
1538            convention: crate::graph::unified::edge::kind::FfiConvention::C,
1539        };
1540        assert!(is_followable_edge(&ffi, 0.5));
1541        assert!(is_followable_edge(&ffi, 0.6));
1542        assert!(!is_followable_edge(&ffi, 0.7));
1543
1544        // Defines has 0.3 confidence
1545        assert!(is_followable_edge(&EdgeKind::Defines, 0.3));
1546        assert!(!is_followable_edge(&EdgeKind::Defines, 0.4));
1547    }
1548}