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}