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 redb::ReadableDatabase;
7use sinter_core::{Confidence, CorpusScope, Edge, Evidence, Node, NodeId, Relation};
8
9use crate::error::StoreError;
10use crate::store::{FILE_SCOPE, IN_EDGES, NODE_SCOPE, NODES, OUT_EDGES, Store};
11
12/// Per-node scope through the single resolution rule in `scope::resolve`.
13fn scope_of(
14    node_scopes: &impl redb::ReadableTable<&'static str, &'static str>,
15    file_scopes: &impl redb::ReadableTable<&'static str, &'static str>,
16    id: &str,
17) -> Result<CorpusScope, StoreError> {
18    let file = id.split_once('#').map_or(id, |(file, _)| file);
19    let file_scope = file_scopes
20        .get(file)?
21        .and_then(|guard| CorpusScope::from_str_opt(guard.value()));
22    Ok(crate::scope::resolve(
23        |key| {
24            node_scopes
25                .get(key)
26                .ok()
27                .flatten()
28                .and_then(|guard| CorpusScope::from_str_opt(guard.value()))
29        },
30        file_scope,
31        id,
32        file,
33    ))
34}
35
36/// Which edges a traversal may walk. Containment is structure, not
37/// dependency, so it never participates.
38#[derive(Debug, Default, Clone)]
39pub struct EdgeFilter {
40    /// Allowed evidence kinds; None = all.
41    pub evidence: Option<BTreeSet<Evidence>>,
42    /// Minimum confidence; None = any.
43    pub min_confidence: Option<Confidence>,
44    /// Allowed relations; None = all (Contains stays excluded either way).
45    pub relations: Option<BTreeSet<Relation>>,
46    /// Allowed corpus roles for nodes entered after the explicit start.
47    /// None = all. Exact lookup of the start remains independent of scope.
48    pub scopes: Option<BTreeSet<CorpusScope>>,
49}
50
51impl EdgeFilter {
52    pub fn admits(&self, edge: &Edge) -> bool {
53        if edge.relation == Relation::Contains {
54            return false;
55        }
56        if let Some(allowed) = &self.relations
57            && !allowed.contains(&edge.relation)
58        {
59            return false;
60        }
61        if let Some(allowed) = &self.evidence
62            && !allowed.contains(&edge.evidence)
63        {
64            return false;
65        }
66        if self.min_confidence == Some(Confidence::Certain)
67            && edge.confidence != Confidence::Certain
68        {
69            return false;
70        }
71        true
72    }
73
74    pub fn admits_scope(&self, scope: CorpusScope) -> bool {
75        self.scopes
76            .as_ref()
77            .is_none_or(|allowed| allowed.contains(&scope))
78    }
79}
80
81/// One step of a traversal result: the node reached, how deep, and the edge
82/// that reached it.
83pub struct Reached {
84    pub node: Node,
85    pub depth: usize,
86    pub via: Edge,
87}
88
89/// Depth-1 dependents and the distinct files they live in — the "who
90/// actually calls this" number, distinct from the transitive total that
91/// otherwise reads as a caller count.
92pub fn direct_summary(reached: &[Reached]) -> (usize, usize) {
93    // File `use` lines are dependents, not callers: keep them out of "direct".
94    let direct: Vec<&Reached> = reached
95        .iter()
96        .filter(|r| r.depth == 1 && r.via.relation != Relation::Imports)
97        .collect();
98    let files: std::collections::HashSet<&str> =
99        direct.iter().map(|r| r.node.file.as_str()).collect();
100    (direct.len(), files.len())
101}
102
103impl Store {
104    /// Reverse blast radius: everything transitively depending on `id`
105    /// (incoming non-Contains edges), breadth-first, deduplicated.
106    pub fn dependents(
107        &self,
108        id: &NodeId,
109        filter: &EdgeFilter,
110        max_depth: usize,
111    ) -> Result<Vec<Reached>, StoreError> {
112        let txn = self.db.begin_read()?;
113        let nodes = txn.open_table(NODES)?;
114        let scopes = txn.open_table(FILE_SCOPE)?;
115        let node_scopes = txn.open_table(NODE_SCOPE)?;
116        let incoming = txn.open_multimap_table(IN_EDGES)?;
117        let mut seen: HashSet<NodeId> = HashSet::from([id.clone()]);
118        let mut queue: VecDeque<(NodeId, usize)> = VecDeque::from([(id.clone(), 0)]);
119        let mut out = Vec::new();
120        while let Some((current, depth)) = queue.pop_front() {
121            if depth >= max_depth {
122                continue;
123            }
124            for guard in incoming.get(current.as_str())? {
125                let edge: Edge = postcard::from_bytes(guard?.value())?;
126                let scope = scope_of(&node_scopes, &scopes, edge.src.as_str())?;
127                if !filter.admits(&edge)
128                    || !filter.admits_scope(scope)
129                    || !seen.insert(edge.src.clone())
130                {
131                    continue;
132                }
133                if let Some(guard) = nodes.get(edge.src.as_str())? {
134                    let node = postcard::from_bytes(guard.value())?;
135                    queue.push_back((edge.src.clone(), depth + 1));
136                    out.push(Reached {
137                        node,
138                        depth: depth + 1,
139                        via: edge,
140                    });
141                }
142            }
143        }
144        Ok(out)
145    }
146
147    /// Forward transitive closure: everything `id` depends on (outgoing
148    /// non-Contains edges), breadth-first, deduplicated. A file start seeds
149    /// through its Contains edges (a file's dependencies live in the
150    /// symbols it contains), silently — containment is not a dependency.
151    pub fn dependencies(
152        &self,
153        id: &NodeId,
154        filter: &EdgeFilter,
155        max_depth: usize,
156    ) -> Result<Vec<Reached>, StoreError> {
157        let txn = self.db.begin_read()?;
158        let nodes = txn.open_table(NODES)?;
159        let scopes = txn.open_table(FILE_SCOPE)?;
160        let node_scopes = txn.open_table(NODE_SCOPE)?;
161        let outgoing = txn.open_multimap_table(OUT_EDGES)?;
162        let mut seen: HashSet<NodeId> = HashSet::from([id.clone()]);
163        let mut queue: VecDeque<(NodeId, usize)> = VecDeque::from([(id.clone(), 0)]);
164        if nodes
165            .get(id.as_str())?
166            .map(|guard| postcard::from_bytes::<Node>(guard.value()))
167            .transpose()?
168            .is_some_and(|n| n.kind == sinter_core::SymbolKind::File)
169        {
170            for guard in outgoing.get(id.as_str())? {
171                let edge: Edge = postcard::from_bytes(guard?.value())?;
172                if edge.relation == Relation::Contains && seen.insert(edge.dst.clone()) {
173                    queue.push_back((edge.dst.clone(), 0));
174                }
175            }
176        }
177        let mut out = Vec::new();
178        while let Some((current, depth)) = queue.pop_front() {
179            if depth >= max_depth {
180                continue;
181            }
182            for guard in outgoing.get(current.as_str())? {
183                let edge: Edge = postcard::from_bytes(guard?.value())?;
184                let scope = scope_of(&node_scopes, &scopes, edge.dst.as_str())?;
185                if !filter.admits(&edge)
186                    || !filter.admits_scope(scope)
187                    || !seen.insert(edge.dst.clone())
188                {
189                    continue;
190                }
191                if let Some(guard) = nodes.get(edge.dst.as_str())? {
192                    let node = postcard::from_bytes(guard.value())?;
193                    queue.push_back((edge.dst.clone(), depth + 1));
194                    out.push(Reached {
195                        node,
196                        depth: depth + 1,
197                        via: edge,
198                    });
199                }
200            }
201        }
202        Ok(out)
203    }
204
205    /// Shortest edge path `from -> to` over outgoing edges, or None.
206    pub fn shortest_path(
207        &self,
208        from: &NodeId,
209        to: &NodeId,
210        filter: &EdgeFilter,
211    ) -> Result<Option<Vec<Edge>>, StoreError> {
212        let txn = self.db.begin_read()?;
213        let nodes = txn.open_table(NODES)?;
214        let scopes = txn.open_table(FILE_SCOPE)?;
215        let node_scopes = txn.open_table(NODE_SCOPE)?;
216        let outgoing = txn.open_multimap_table(OUT_EDGES)?;
217        let mut prev: HashMap<NodeId, Edge> = HashMap::new();
218        let mut seen: HashSet<NodeId> = HashSet::from([from.clone()]);
219        let mut queue: VecDeque<NodeId> = VecDeque::from([from.clone()]);
220        // A file's dependencies live in the symbols it contains; a file
221        // start seeds through its contains edges (shown as path steps).
222        // Containment stays non-traversable everywhere past the start.
223        if nodes
224            .get(from.as_str())?
225            .map(|guard| postcard::from_bytes::<Node>(guard.value()))
226            .transpose()?
227            .is_some_and(|n| n.kind == sinter_core::SymbolKind::File)
228        {
229            for guard in outgoing.get(from.as_str())? {
230                let edge: Edge = postcard::from_bytes(guard?.value())?;
231                if edge.relation == Relation::Contains && seen.insert(edge.dst.clone()) {
232                    prev.insert(edge.dst.clone(), edge.clone());
233                    queue.push_back(edge.dst.clone());
234                }
235            }
236        }
237        while let Some(current) = queue.pop_front() {
238            if &current == to {
239                let mut path = Vec::new();
240                let mut at = to.clone();
241                while &at != from {
242                    let edge = prev[&at].clone();
243                    at = edge.src.clone();
244                    path.push(edge);
245                }
246                path.reverse();
247                return Ok(Some(path));
248            }
249            for guard in outgoing.get(current.as_str())? {
250                let edge: Edge = postcard::from_bytes(guard?.value())?;
251                let scope = scope_of(&node_scopes, &scopes, edge.dst.as_str())?;
252                if !filter.admits(&edge)
253                    || !filter.admits_scope(scope)
254                    || !seen.insert(edge.dst.clone())
255                {
256                    continue;
257                }
258                prev.insert(edge.dst.clone(), edge.clone());
259                queue.push_back(edge.dst.clone());
260            }
261        }
262        Ok(None)
263    }
264}