Skip to main content

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(&current) {
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}