Skip to main content

brink_analyzer/infer/
graph.rs

1//! Call-graph construction over inferable (knot/stitch) definitions, and the
2//! strongly-connected-component decomposition that drives the "SCC fixpoint
3//! for recursion" rule (typed-mode-spec §2).
4//!
5//! An edge `caller -> callee` means caller's body contains a resolved call
6//! or divert-with-arguments target pointing at callee. [`topo_order`]
7//! batches the graph's SCCs so [`super::infer_project`] can solve each batch
8//! using only the already-finalized signatures of earlier batches, plus
9//! (within a batch) the in-progress fixpoint estimates of its own members —
10//! the signature firewall (`infer_body(A)` reads only `signature(B)`) holds
11//! at the batch boundary; *within* a mutually-recursive batch, "signature"
12//! means "this SCC's current fixpoint estimate", which is exactly what a
13//! Haskell-style monomorphic binding-group solves.
14//!
15//! ## Why reachability sets, not Tarjan/Kosaraju
16//!
17//! A linear-time SCC algorithm earns its keep on graphs with thousands of
18//! nodes. Ink call graphs (knots + stitches in a single project) are
19//! reliably small — the corpus tops out in the hundreds — and this query is
20//! not on any hot path yet (nothing calls it: see the module doc on
21//! `infer_project`'s laziness). Correctness under review is worth far more
22//! here than an asymptotic win nothing exercises: computing forward- and
23//! backward-reachability per node via `BTreeSet` (`O(V * (V + E))`) is
24//! straightforward to read, has no recursion-depth risk on deep call chains,
25//! and is dead simple to verify against a handful of shape tests (linear
26//! chain, mutual pair, self-loop, disconnected diamond).
27
28use std::collections::{BTreeMap, BTreeSet};
29
30use brink_format::DefinitionId;
31
32/// A directed call graph over inferable definitions.
33///
34/// `PartialEq`/`Eq` (FG-2, issue #631): the salsa cutoff for
35/// `call_graph_query` in `brink-db` — an edit that leaves every def's call
36/// targets unchanged leaves this equal, so `scc_membership_query` and every
37/// `solve_scc_query` backdate instead of re-executing.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct CallGraph {
40    pub nodes: BTreeSet<DefinitionId>,
41    /// `caller -> { callees }`.
42    pub edges: BTreeMap<DefinitionId, BTreeSet<DefinitionId>>,
43}
44
45impl CallGraph {
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn add_node(&mut self, def: DefinitionId) {
52        self.nodes.insert(def);
53        self.edges.entry(def).or_default();
54    }
55
56    /// Record a resolved call/divert-target edge. Both endpoints are added
57    /// as nodes if not already present (a call to a def with no
58    /// inferable body of its own — e.g. an external — is simply never added
59    /// as a node by the caller, so no edge is recorded for it; see
60    /// `infer_project`'s node-selection pass).
61    pub fn add_edge(&mut self, from: DefinitionId, to: DefinitionId) {
62        self.add_node(from);
63        self.add_node(to);
64        self.edges.entry(from).or_default().insert(to);
65    }
66
67    /// Nodes reachable from `start` via forward edges, `start` included.
68    fn reachable_forward(&self, start: DefinitionId) -> BTreeSet<DefinitionId> {
69        let mut seen = BTreeSet::new();
70        let mut stack = vec![start];
71        while let Some(n) = stack.pop() {
72            if seen.insert(n)
73                && let Some(callees) = self.edges.get(&n)
74            {
75                stack.extend(callees.iter().copied());
76            }
77        }
78        seen
79    }
80
81    /// Nodes that reach `target` via forward edges, `target` included
82    /// (forward reachability on the transposed graph).
83    fn reachable_backward(&self, target: DefinitionId) -> BTreeSet<DefinitionId> {
84        let mut seen = BTreeSet::new();
85        let mut stack = vec![target];
86        while let Some(n) = stack.pop() {
87            if seen.insert(n) {
88                for (caller, callees) in &self.edges {
89                    if callees.contains(&n) {
90                        stack.push(*caller);
91                    }
92                }
93            }
94        }
95        seen
96    }
97}
98
99/// Partition the graph into strongly-connected components: `u` and `v` share
100/// a component iff each is reachable from the other (`u == v` always forms
101/// its own singleton component, self-loop or not — direct recursion is a
102/// component of size one with a self-edge, not folded into anything).
103///
104/// Deterministic: nodes are visited in `BTreeSet` order, every intermediate
105/// collection is a `BTreeSet`, and the returned `Vec` is sorted by each
106/// component's minimum member — the same partition, same order, regardless
107/// of insertion history.
108#[must_use]
109pub fn strongly_connected_components(graph: &CallGraph) -> Vec<BTreeSet<DefinitionId>> {
110    let mut assigned: BTreeSet<DefinitionId> = BTreeSet::new();
111    let mut components: Vec<BTreeSet<DefinitionId>> = Vec::new();
112
113    for &node in &graph.nodes {
114        if assigned.contains(&node) {
115            continue;
116        }
117        let fwd = graph.reachable_forward(node);
118        let bwd = graph.reachable_backward(node);
119        let component: BTreeSet<DefinitionId> = fwd.intersection(&bwd).copied().collect();
120        assigned.extend(component.iter().copied());
121        components.push(component);
122    }
123
124    components.sort_by_key(|c| c.iter().next().copied());
125    components
126}
127
128/// [`scc_graph`]'s output: the dependency-ordered component membership plus
129/// the condensation DAG's adjacency, keyed by each component's stable id —
130/// its own minimum member (FG-2, issue #631's `scc_membership()` query;
131/// `SccId` is a plain `DefinitionId` in `brink-db`'s query layer, per the
132/// design doc's "already the sort key in graph.rs" note).
133///
134/// `PartialEq`/`Eq`: the salsa cutoff for `scc_membership_query` — an edit
135/// that leaves the call graph's SCC partition and condensation identical
136/// leaves this equal, so every `solve_scc_query` backdates.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct SccGraph {
139    /// Every component, in the same dependency order [`topo_order`]
140    /// returns: a component appears after every *other* component it calls.
141    pub order: Vec<BTreeSet<DefinitionId>>,
142    /// `component id -> { other component ids it calls }` — the condensation
143    /// adjacency. `solve_scc(S)` reads `solve_scc(T)` for each `T` in
144    /// `depends_on[S]`; the condensation is a DAG (SCCs are maximal by
145    /// construction), so this recursion is always acyclic — no salsa cycles
146    /// (Fork 1 ruling, design doc §8).
147    pub depends_on: BTreeMap<DefinitionId, BTreeSet<DefinitionId>>,
148    /// `def -> its component's id` — the reverse index `inferred_signature(def)`
149    /// / `infer_body(def)` use to find which `solve_scc` result to read.
150    pub member_of: BTreeMap<DefinitionId, DefinitionId>,
151}
152
153/// Partition `graph` into SCCs and compute the condensation DAG in one pass
154/// (FG-2, issue #631). [`topo_order`] is now a thin projection of this
155/// (`.order`) kept for its own existing tests/callers; `scc_membership_query`
156/// is the new consumer that needs the adjacency too.
157#[must_use]
158pub fn scc_graph(graph: &CallGraph) -> SccGraph {
159    let components = strongly_connected_components(graph);
160    if components.is_empty() {
161        return SccGraph::default();
162    }
163
164    // Map each node to the index of its component in `components`, and each
165    // component's index to its stable id (its own minimum member).
166    let mut owner: BTreeMap<DefinitionId, usize> = BTreeMap::new();
167    for (idx, comp) in components.iter().enumerate() {
168        for &n in comp {
169            owner.insert(n, idx);
170        }
171    }
172    let component_key = |idx: usize| components[idx].iter().next().copied();
173
174    // Condensation adjacency: component -> { other components it calls }.
175    let mut depends_on_idx: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
176    let mut dependents_idx: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
177    for idx in 0..components.len() {
178        depends_on_idx.insert(idx, BTreeSet::new());
179        dependents_idx.insert(idx, BTreeSet::new());
180    }
181    for (caller, callees) in &graph.edges {
182        let Some(&from) = owner.get(caller) else {
183            continue;
184        };
185        for callee in callees {
186            let Some(&to) = owner.get(callee) else {
187                continue;
188            };
189            if from != to {
190                depends_on_idx.entry(from).or_default().insert(to);
191                dependents_idx.entry(to).or_default().insert(from);
192            }
193        }
194    }
195
196    // Kahn's algorithm: a component is ready once every component it calls
197    // has already been placed in `order`.
198    let mut remaining = depends_on_idx.clone();
199    let mut order_idx: Vec<usize> = Vec::new();
200
201    loop {
202        let mut ready: Vec<usize> = remaining
203            .iter()
204            .filter(|(_, deps)| deps.is_empty())
205            .map(|(&idx, _)| idx)
206            .collect();
207        if ready.is_empty() {
208            break;
209        }
210        ready.sort_by_key(|&idx| component_key(idx));
211        for idx in ready {
212            if remaining.remove(&idx).is_none() {
213                // Already processed via an earlier `idx` in this same batch
214                // sharing dependents — cannot happen since `remaining` is
215                // the source of truth we just filtered from, but stay
216                // defensive rather than double-count.
217                continue;
218            }
219            order_idx.push(idx);
220            if let Some(deps) = dependents_idx.get(&idx) {
221                for &dep in deps {
222                    if let Some(set) = remaining.get_mut(&dep) {
223                        set.remove(&idx);
224                    }
225                }
226            }
227        }
228    }
229
230    // `remaining` is only non-empty here if the condensation had a cycle,
231    // which cannot happen (SCCs are maximal by construction — any cycle
232    // would have been folded into one component). Guard against silently
233    // dropping components anyway (house rule: never silently drop data) by
234    // appending whatever is left, deterministically ordered, rather than
235    // assuming the impossible-in-theory case away.
236    let mut leftover: Vec<usize> = remaining.keys().copied().collect();
237    leftover.sort_by_key(|&idx| component_key(idx));
238    order_idx.extend(leftover);
239
240    let order: Vec<BTreeSet<DefinitionId>> = order_idx
241        .into_iter()
242        .map(|idx| components[idx].clone())
243        .collect();
244
245    let mut depends_on: BTreeMap<DefinitionId, BTreeSet<DefinitionId>> = BTreeMap::new();
246    let mut member_of: BTreeMap<DefinitionId, DefinitionId> = BTreeMap::new();
247    for (idx, comp) in components.iter().enumerate() {
248        let Some(comp_id) = component_key(idx) else {
249            continue;
250        };
251        for &member in comp {
252            member_of.insert(member, comp_id);
253        }
254        let deps: BTreeSet<DefinitionId> = depends_on_idx
255            .get(&idx)
256            .into_iter()
257            .flatten()
258            .filter_map(|&dep_idx| component_key(dep_idx))
259            .collect();
260        depends_on.insert(comp_id, deps);
261    }
262
263    SccGraph {
264        order,
265        depends_on,
266        member_of,
267    }
268}
269
270/// Order SCCs so every component appears after every *other* component it
271/// calls into (a component's own self-edges/internal edges never block it).
272/// Ties (independent components, or components with no cross-component
273/// calls) break on the component's minimum member for determinism.
274///
275/// This is the processing order [`super::infer_project`] uses: by the time a
276/// component is solved, every component it depends on already has a
277/// finalized signature — the cross-SCC half of the firewall. Within one
278/// returned component, callers may be mutually recursive with callees
279/// (that's what makes it one SCC); the caller solves those together via
280/// fixpoint, not via this ordering.
281#[must_use]
282pub fn topo_order(graph: &CallGraph) -> Vec<BTreeSet<DefinitionId>> {
283    scc_graph(graph).order
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use brink_format::DefinitionTag;
290
291    fn def(n: u64) -> DefinitionId {
292        DefinitionId::new(DefinitionTag::Address, n)
293    }
294
295    #[test]
296    fn empty_graph_has_no_components() {
297        let g = CallGraph::new();
298        assert!(topo_order(&g).is_empty());
299    }
300
301    #[test]
302    fn isolated_node_is_its_own_component() {
303        let mut g = CallGraph::new();
304        g.add_node(def(1));
305        let sccs = strongly_connected_components(&g);
306        assert_eq!(sccs, vec![BTreeSet::from([def(1)])]);
307    }
308
309    #[test]
310    fn direct_recursion_is_a_singleton_component() {
311        let mut g = CallGraph::new();
312        g.add_edge(def(1), def(1));
313        let sccs = strongly_connected_components(&g);
314        assert_eq!(sccs, vec![BTreeSet::from([def(1)])]);
315    }
316
317    #[test]
318    fn linear_chain_orders_callee_before_caller() {
319        // a -> b -> c (a calls b, b calls c).
320        let mut g = CallGraph::new();
321        g.add_edge(def(1), def(2));
322        g.add_edge(def(2), def(3));
323        let order = topo_order(&g);
324        let flat: Vec<DefinitionId> = order.into_iter().flatten().collect();
325        let pos = |d: DefinitionId| flat.iter().position(|&x| x == d).expect("present");
326        assert!(pos(def(3)) < pos(def(2)), "callee c before caller b");
327        assert!(pos(def(2)) < pos(def(1)), "callee b before caller a");
328    }
329
330    #[test]
331    fn mutual_recursion_is_one_component() {
332        // a <-> b mutually recursive; c calls a (external caller).
333        let mut g = CallGraph::new();
334        g.add_edge(def(1), def(2));
335        g.add_edge(def(2), def(1));
336        g.add_edge(def(3), def(1));
337        let sccs = strongly_connected_components(&g);
338        let ab = sccs
339            .iter()
340            .find(|c| c.contains(&def(1)))
341            .expect("component containing a");
342        assert_eq!(
343            ab,
344            &BTreeSet::from([def(1), def(2)]),
345            "a and b fold into one SCC"
346        );
347
348        let order = topo_order(&g);
349        let ab_idx = order
350            .iter()
351            .position(|c| c.contains(&def(1)))
352            .expect("ab component present");
353        let c_idx = order
354            .iter()
355            .position(|c| c.contains(&def(3)))
356            .expect("c component present");
357        assert!(
358            ab_idx < c_idx,
359            "the mutually-recursive pair solves before its caller"
360        );
361    }
362
363    #[test]
364    fn disconnected_components_both_appear() {
365        let mut g = CallGraph::new();
366        g.add_edge(def(1), def(2));
367        g.add_edge(def(10), def(20));
368        let order = topo_order(&g);
369        let flat: BTreeSet<DefinitionId> = order.into_iter().flatten().collect();
370        assert_eq!(flat, BTreeSet::from([def(1), def(2), def(10), def(20)]));
371    }
372
373    // ── scc_graph (FG-2, issue #631) ───────────────────────────────────
374
375    #[test]
376    fn scc_graph_order_matches_topo_order() {
377        // scc_graph must agree with topo_order (a thin projection of it) on
378        // every shape already covered above — the refactor must not change
379        // topo_order's own behavior.
380        let mut g = CallGraph::new();
381        g.add_edge(def(1), def(2));
382        g.add_edge(def(2), def(3));
383        g.add_edge(def(4), def(1));
384        assert_eq!(scc_graph(&g).order, topo_order(&g));
385    }
386
387    #[test]
388    fn scc_graph_member_of_maps_every_node_to_its_component_id() {
389        // a <-> b mutually recursive (component id = min(a, b) = a); c is a
390        // singleton component (component id = c).
391        let mut g = CallGraph::new();
392        g.add_edge(def(1), def(2));
393        g.add_edge(def(2), def(1));
394        g.add_edge(def(3), def(1));
395        let sg = scc_graph(&g);
396        assert_eq!(sg.member_of.get(&def(1)), Some(&def(1)));
397        assert_eq!(sg.member_of.get(&def(2)), Some(&def(1)));
398        assert_eq!(sg.member_of.get(&def(3)), Some(&def(3)));
399    }
400
401    #[test]
402    fn scc_graph_depends_on_is_the_condensation_adjacency() {
403        // a -> b -> c: each singleton component depends on exactly the next
404        // one down the chain (component ids equal the node ids here).
405        let mut g = CallGraph::new();
406        g.add_edge(def(1), def(2));
407        g.add_edge(def(2), def(3));
408        let sg = scc_graph(&g);
409        assert_eq!(sg.depends_on.get(&def(1)), Some(&BTreeSet::from([def(2)])));
410        assert_eq!(sg.depends_on.get(&def(2)), Some(&BTreeSet::from([def(3)])));
411        assert_eq!(sg.depends_on.get(&def(3)), Some(&BTreeSet::new()));
412    }
413
414    #[test]
415    fn scc_graph_depends_on_never_names_the_components_own_id() {
416        // Internal/self edges within a component must never appear in its
417        // own depends_on entry (only *other* components it calls) — a
418        // mutually-recursive pair's condensation entry is empty even though
419        // a and b call each other constantly.
420        let mut g = CallGraph::new();
421        g.add_edge(def(1), def(2));
422        g.add_edge(def(2), def(1));
423        let sg = scc_graph(&g);
424        assert_eq!(sg.depends_on.get(&def(1)), Some(&BTreeSet::new()));
425    }
426
427    #[test]
428    fn scc_graph_empty_graph_is_empty() {
429        let g = CallGraph::new();
430        assert_eq!(scc_graph(&g), SccGraph::default());
431    }
432}