Skip to main content

sinter_store/
traverse.rs

1//! Graph traversals over the persisted adjacency tables: reverse blast
2//! radius and shortest path. Point reads only — never loads the corpus.
3
4use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
5
6use sinter_core::{Confidence, Edge, Evidence, Node, NodeId, Relation};
7
8use crate::error::StoreError;
9use crate::store::Store;
10
11/// Which edges a traversal may walk. Containment is structure, not
12/// dependency, so it never participates.
13#[derive(Debug, Default, Clone)]
14pub struct EdgeFilter {
15    /// Allowed evidence kinds; None = all.
16    pub evidence: Option<BTreeSet<Evidence>>,
17    /// Minimum confidence; None = any.
18    pub min_confidence: Option<Confidence>,
19    /// Allowed relations; None = all (Contains stays excluded either way).
20    pub relations: Option<BTreeSet<Relation>>,
21}
22
23impl EdgeFilter {
24    pub fn admits(&self, edge: &Edge) -> bool {
25        if edge.relation == Relation::Contains {
26            return false;
27        }
28        if let Some(allowed) = &self.relations
29            && !allowed.contains(&edge.relation)
30        {
31            return false;
32        }
33        if let Some(allowed) = &self.evidence
34            && !allowed.contains(&edge.evidence)
35        {
36            return false;
37        }
38        if self.min_confidence == Some(Confidence::Certain)
39            && edge.confidence != Confidence::Certain
40        {
41            return false;
42        }
43        true
44    }
45}
46
47/// One step of a traversal result: the node reached, how deep, and the edge
48/// that reached it.
49pub struct Reached {
50    pub node: Node,
51    pub depth: usize,
52    pub via: Edge,
53}
54
55/// Depth-1 dependents and the distinct files they live in — the "who
56/// actually calls this" number, distinct from the transitive total that
57/// otherwise reads as a caller count.
58pub fn direct_summary(reached: &[Reached]) -> (usize, usize) {
59    let direct: Vec<&Reached> = reached.iter().filter(|r| r.depth == 1).collect();
60    let files: std::collections::HashSet<&str> =
61        direct.iter().map(|r| r.node.file.as_str()).collect();
62    (direct.len(), files.len())
63}
64
65impl Store {
66    /// Reverse blast radius: everything transitively depending on `id`
67    /// (incoming non-Contains edges), breadth-first, deduplicated.
68    pub fn dependents(
69        &self,
70        id: &NodeId,
71        filter: &EdgeFilter,
72        max_depth: usize,
73    ) -> Result<Vec<Reached>, StoreError> {
74        let mut seen: HashSet<NodeId> = HashSet::from([id.clone()]);
75        let mut queue: VecDeque<(NodeId, usize)> = VecDeque::from([(id.clone(), 0)]);
76        let mut out = Vec::new();
77        while let Some((current, depth)) = queue.pop_front() {
78            if depth >= max_depth {
79                continue;
80            }
81            for edge in self.in_edges(&current)? {
82                if !filter.admits(&edge) || !seen.insert(edge.src.clone()) {
83                    continue;
84                }
85                if let Some(node) = self.node(&edge.src)? {
86                    queue.push_back((edge.src.clone(), depth + 1));
87                    out.push(Reached {
88                        node,
89                        depth: depth + 1,
90                        via: edge,
91                    });
92                }
93            }
94        }
95        Ok(out)
96    }
97
98    /// Forward transitive closure: everything `id` depends on (outgoing
99    /// non-Contains edges), breadth-first, deduplicated. A file start seeds
100    /// through its Contains edges (a file's dependencies live in the
101    /// symbols it contains), silently — containment is not a dependency.
102    pub fn dependencies(
103        &self,
104        id: &NodeId,
105        filter: &EdgeFilter,
106        max_depth: usize,
107    ) -> Result<Vec<Reached>, StoreError> {
108        let mut seen: HashSet<NodeId> = HashSet::from([id.clone()]);
109        let mut queue: VecDeque<(NodeId, usize)> = VecDeque::from([(id.clone(), 0)]);
110        if self
111            .node(id)?
112            .is_some_and(|n| n.kind == sinter_core::SymbolKind::File)
113        {
114            for edge in self.out_edges(id)? {
115                if edge.relation == Relation::Contains && seen.insert(edge.dst.clone()) {
116                    queue.push_back((edge.dst.clone(), 0));
117                }
118            }
119        }
120        let mut out = Vec::new();
121        while let Some((current, depth)) = queue.pop_front() {
122            if depth >= max_depth {
123                continue;
124            }
125            for edge in self.out_edges(&current)? {
126                if !filter.admits(&edge) || !seen.insert(edge.dst.clone()) {
127                    continue;
128                }
129                if let Some(node) = self.node(&edge.dst)? {
130                    queue.push_back((edge.dst.clone(), depth + 1));
131                    out.push(Reached {
132                        node,
133                        depth: depth + 1,
134                        via: edge,
135                    });
136                }
137            }
138        }
139        Ok(out)
140    }
141
142    /// Shortest edge path `from -> to` over outgoing edges, or None.
143    pub fn shortest_path(
144        &self,
145        from: &NodeId,
146        to: &NodeId,
147        filter: &EdgeFilter,
148    ) -> Result<Option<Vec<Edge>>, StoreError> {
149        let mut prev: HashMap<NodeId, Edge> = HashMap::new();
150        let mut seen: HashSet<NodeId> = HashSet::from([from.clone()]);
151        let mut queue: VecDeque<NodeId> = VecDeque::from([from.clone()]);
152        // A file's dependencies live in the symbols it contains; a file
153        // start seeds through its contains edges (shown as path steps).
154        // Containment stays non-traversable everywhere past the start.
155        if self
156            .node(from)?
157            .is_some_and(|n| n.kind == sinter_core::SymbolKind::File)
158        {
159            for edge in self.out_edges(from)? {
160                if edge.relation == Relation::Contains && seen.insert(edge.dst.clone()) {
161                    prev.insert(edge.dst.clone(), edge.clone());
162                    queue.push_back(edge.dst.clone());
163                }
164            }
165        }
166        while let Some(current) = queue.pop_front() {
167            if &current == to {
168                let mut path = Vec::new();
169                let mut at = to.clone();
170                while &at != from {
171                    let edge = prev[&at].clone();
172                    at = edge.src.clone();
173                    path.push(edge);
174                }
175                path.reverse();
176                return Ok(Some(path));
177            }
178            for edge in self.out_edges(&current)? {
179                if !filter.admits(&edge) || !seen.insert(edge.dst.clone()) {
180                    continue;
181                }
182                prev.insert(edge.dst.clone(), edge.clone());
183                queue.push_back(edge.dst.clone());
184            }
185        }
186        Ok(None)
187    }
188}