sqry_core/visualization/subgraph.rs
1//! Shared seeded, depth-limited subgraph builder.
2//!
3//! This is the reusable traversal primitive behind seeded exports (today the
4//! Archify architecture exporter). It is deliberately **entry-point-agnostic**:
5//! it takes a set of concrete [`NodeId`] seeds and walks the graph. Resolving
6//! which nodes are seeds (by symbol name, file, or entry-point heuristic) is a
7//! higher-layer concern and stays out of `sqry-core` (entry-point detection
8//! lives in `sqry-db`). The builder never invents seeds.
9//!
10//! It is also **format-agnostic**: the caller supplies an edge classifier that
11//! decides, per [`EdgeKind`], whether an edge is retained (and under what
12//! caller-defined class) and whether the traversal follows it. This keeps each
13//! export format's edge-retention policy fully independent. In particular the
14//! Archify path classifies (and retains) semantically rich cross-service edges
15//! (`HttpRequest`, `DbQuery`, `MessageQueue`, ...) that the raw dot / d2 /
16//! mermaid / json exporters intentionally drop, and it does so through its own
17//! classifier so those existing formats are provably unchanged.
18//!
19//! # Determinism
20//!
21//! Every ordering decision is explicit and stable so identical input yields
22//! byte-identical output, including across parallel (rayon) graph rebuilds
23//! where the underlying arena/edge iteration order can differ:
24//!
25//! - Seeds are sorted by `NodeId` index and de-duplicated before the walk.
26//! - Outgoing edges of each node are sorted by
27//! `(target index, edge-kind discriminant, sequence)` before they are
28//! recorded or enqueued.
29//! - BFS uses a per-node visited set keyed on `NodeId` (never on qualified
30//! name), so same-named nodes in unrelated files never fuse.
31//!
32//! The resulting [`SeededSubgraph::nodes`] is in stable BFS discovery order and
33//! [`SeededSubgraph::edges`] is in stable emission order.
34
35use std::collections::{HashSet, VecDeque};
36
37use crate::graph::node::Language;
38use crate::graph::unified::concurrent::GraphSnapshot;
39use crate::graph::unified::edge::EdgeKind;
40use crate::graph::unified::node::NodeId;
41
42/// Default BFS depth (hops from the seed) for a seeded subgraph.
43pub const DEFAULT_MAX_DEPTH: usize = 2;
44
45/// Hard cap on BFS depth. Anything deeper is already too dense to read.
46pub const MAX_DEPTH_CAP: usize = 5;
47
48/// Default cap on the number of raw nodes visited during BFS.
49pub const DEFAULT_MAX_RESULTS: usize = 1000;
50
51/// Configuration for a seeded subgraph walk.
52#[derive(Debug, Clone)]
53pub struct SeededSubgraphConfig {
54 /// Maximum BFS hops from the seed set. Clamped to [`MAX_DEPTH_CAP`].
55 pub max_depth: usize,
56 /// Maximum number of raw nodes to visit before the walk stops.
57 pub max_results: usize,
58 /// Optional language allow-list. Empty means "any language". A node whose
59 /// file language is not in the list is skipped (not visited, not expanded).
60 pub languages: Vec<Language>,
61}
62
63impl Default for SeededSubgraphConfig {
64 fn default() -> Self {
65 Self {
66 max_depth: DEFAULT_MAX_DEPTH,
67 max_results: DEFAULT_MAX_RESULTS,
68 languages: Vec::new(),
69 }
70 }
71}
72
73impl SeededSubgraphConfig {
74 /// Clamp `max_depth` to the hard cap and guarantee a sane `max_results`.
75 #[must_use]
76 pub fn normalized(mut self) -> Self {
77 self.max_depth = self.max_depth.min(MAX_DEPTH_CAP);
78 if self.max_results == 0 {
79 self.max_results = DEFAULT_MAX_RESULTS;
80 }
81 self
82 }
83}
84
85/// Decision returned by a caller-supplied edge classifier.
86#[derive(Debug, Clone)]
87pub struct EdgeRetention<C> {
88 /// Caller-defined classification of the retained edge (e.g. a connection
89 /// category for the Archify exporter).
90 pub class: C,
91 /// Whether the BFS should follow this edge to expand the frontier.
92 pub traverse: bool,
93}
94
95/// A retained edge between two visited nodes, carrying the caller's class.
96#[derive(Debug, Clone)]
97pub struct RetainedEdge<C> {
98 /// Source node.
99 pub from: NodeId,
100 /// Target node.
101 pub to: NodeId,
102 /// Caller-defined classification of this edge.
103 pub class: C,
104}
105
106/// The result of a seeded subgraph walk.
107#[derive(Debug, Clone)]
108pub struct SeededSubgraph<C> {
109 /// Visited nodes in stable BFS discovery order.
110 pub nodes: Vec<NodeId>,
111 /// Retained edges (both endpoints visited) in stable emission order.
112 pub edges: Vec<RetainedEdge<C>>,
113 /// `true` if the walk stopped early because `max_results` was reached.
114 pub truncated: bool,
115}
116
117/// Build a seeded, depth-limited subgraph.
118///
119/// `classify_edge` is invoked for every outgoing edge of a visited node. It
120/// returns `Some(EdgeRetention { class, traverse })` to retain the edge under
121/// `class` (and, if `traverse`, to expand into the target), or `None` to drop
122/// the edge entirely. The classifier is the sole edge-retention policy: this
123/// function embeds no format-specific edge knowledge.
124///
125/// A retained edge is only recorded once both endpoints are visited nodes, so
126/// the returned edge list never dangles.
127pub fn build_seeded_subgraph<C, F>(
128 snapshot: &GraphSnapshot,
129 seeds: &[NodeId],
130 config: &SeededSubgraphConfig,
131 classify_edge: F,
132) -> SeededSubgraph<C>
133where
134 F: Fn(&EdgeKind) -> Option<EdgeRetention<C>>,
135 C: Clone,
136{
137 let language_ok = |node: NodeId| -> bool {
138 if config.languages.is_empty() {
139 return true;
140 }
141 snapshot
142 .get_node(node)
143 .and_then(|entry| snapshot.files().language_for_file(entry.file))
144 .is_some_and(|lang| config.languages.contains(&lang))
145 };
146
147 // Deterministic seed order: sort by index, dedup, drop unified losers and
148 // language-filtered seeds.
149 let mut ordered_seeds: Vec<NodeId> = seeds
150 .iter()
151 .copied()
152 .filter(|&id| {
153 snapshot.get_node(id).is_some_and(|e| !e.is_unified_loser()) && language_ok(id)
154 })
155 .collect();
156 ordered_seeds.sort_by_key(|id| id.index());
157 ordered_seeds.dedup();
158
159 let mut visited: HashSet<NodeId> = HashSet::new();
160 let mut discovery: Vec<NodeId> = Vec::new();
161 let mut pending_edges: Vec<(NodeId, NodeId, C)> = Vec::new();
162 let mut queue: VecDeque<(NodeId, usize)> =
163 ordered_seeds.iter().map(|&id| (id, 0usize)).collect();
164 let mut truncated = false;
165
166 while let Some((current, depth)) = queue.pop_front() {
167 if depth > config.max_depth || visited.contains(¤t) {
168 continue;
169 }
170 if !language_ok(current) {
171 continue;
172 }
173 if discovery.len() >= config.max_results {
174 truncated = true;
175 break;
176 }
177 visited.insert(current);
178 discovery.push(current);
179
180 // Collect + deterministically sort this node's outgoing retained edges.
181 let mut outgoing: Vec<(NodeId, EdgeKind, u64, C, bool)> = Vec::new();
182 for edge in snapshot.edges().edges_from(current) {
183 let Some(retention) = classify_edge(&edge.kind) else {
184 continue;
185 };
186 // Skip edges into losers or language-filtered targets up front so
187 // the frontier stays clean.
188 if snapshot
189 .get_node(edge.target)
190 .is_none_or(|e| e.is_unified_loser())
191 || !language_ok(edge.target)
192 {
193 continue;
194 }
195 outgoing.push((
196 edge.target,
197 edge.kind.clone(),
198 edge.seq,
199 retention.class,
200 retention.traverse,
201 ));
202 }
203 outgoing.sort_by(|a, b| {
204 a.0.index()
205 .cmp(&b.0.index())
206 .then_with(|| edge_kind_rank(&a.1).cmp(&edge_kind_rank(&b.1)))
207 .then_with(|| a.2.cmp(&b.2))
208 });
209
210 for (target, _kind, _seq, class, traverse) in outgoing {
211 pending_edges.push((current, target, class));
212 if traverse && depth < config.max_depth && !visited.contains(&target) {
213 queue.push_back((target, depth + 1));
214 }
215 }
216 }
217
218 // Retain only edges whose endpoints were both visited, preserving emission
219 // order. `pending_edges` is already in per-node sorted order.
220 let edges: Vec<RetainedEdge<C>> = pending_edges
221 .into_iter()
222 .filter(|(from, to, _)| visited.contains(from) && visited.contains(to))
223 .map(|(from, to, class)| RetainedEdge { from, to, class })
224 .collect();
225
226 SeededSubgraph {
227 nodes: discovery,
228 edges,
229 truncated,
230 }
231}
232
233/// Stable, discriminant-based rank for an `EdgeKind`.
234///
235/// `std::mem::discriminant` is not `Ord`, so we derive a stable integer from a
236/// fixed match. Two edges of the same variant compare equal here and fall
237/// through to the sequence tie-break, which keeps ordering total and stable
238/// without depending on metadata payloads (arg counts, async flags, string
239/// ids) that can differ harmlessly.
240fn edge_kind_rank(kind: &EdgeKind) -> u16 {
241 // Grouped by architectural weight so the dominant-kind tie-break in the
242 // Archify exporter is stable and intention-revealing.
243 match kind {
244 EdgeKind::HttpRequest { .. } => 0,
245 EdgeKind::GrpcCall { .. } => 1,
246 EdgeKind::DbQuery { .. } => 2,
247 EdgeKind::TableRead { .. } => 3,
248 EdgeKind::TableWrite { .. } => 4,
249 EdgeKind::TriggeredBy { .. } => 5,
250 EdgeKind::MessageQueue { .. } => 6,
251 EdgeKind::WebSocket { .. } => 7,
252 EdgeKind::GraphQLOperation { .. } => 8,
253 EdgeKind::FfiCall { .. } => 9,
254 EdgeKind::WebAssemblyCall => 10,
255 EdgeKind::Calls { .. } => 11,
256 EdgeKind::Imports { .. } => 12,
257 EdgeKind::Exports { .. } => 13,
258 EdgeKind::TypeOf { .. } => 14,
259 EdgeKind::References => 15,
260 EdgeKind::Inherits => 16,
261 EdgeKind::Implements => 17,
262 EdgeKind::Defines => 18,
263 EdgeKind::Contains => 19,
264 // Any remaining variant sorts last but stays deterministic via the
265 // discriminant hash fallback so the ordering is still total.
266 other => 20u16.wrapping_add(discriminant_index(other)),
267 }
268}
269
270/// Deterministic fallback index for edge variants not explicitly ranked.
271///
272/// Only the long tail of JVM/Rust-specific edges reaches this arm, and the
273/// Archify classifier never retains them, so it never affects real output; it
274/// exists purely to keep [`edge_kind_rank`] total. The debug label is stable
275/// within a build and identical across rebuilds of the same source, so folding
276/// it keeps ordering deterministic.
277fn discriminant_index(kind: &EdgeKind) -> u16 {
278 let label = format!("{kind:?}");
279 let mut acc: u16 = 0;
280 for b in label.bytes().take(8) {
281 acc = acc.wrapping_mul(31).wrapping_add(u16::from(b));
282 }
283 acc
284}