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