Skip to main content

llm_kernel/graph/
traversal.rs

1//! Graph traversal: 1-hop neighbors and BFS via recursive CTEs.
2
3use std::collections::HashSet;
4
5use rusqlite::{Connection, params};
6
7use super::store::{read_edges, read_nodes};
8use super::types::{EdgeDirection, Graph, GraphEdge, GraphNodeSummary};
9
10/// Maximum edges in a graph snapshot (prevents unbounded memory).
11const MAX_GRAPH_EDGES: usize = 2000;
12
13/// Maximum seed IDs per neighbor query (keeps SQLite bind variables under limit).
14const MAX_SEED_IDS: usize = 100;
15
16/// Get 1-hop neighbors from seed IDs. Returns `(neighbor_id, total_weight)`
17/// sorted by weight DESC.
18///
19/// Follows edges in both directions (source→target and target→source).
20/// Seed nodes are excluded from results.
21///
22/// This is a convenience alias for `neighbors_weighted` with
23/// [`EdgeDirection::Both`] and no relation filter, preserving the historical
24/// bidirectional behavior.
25pub fn graph_neighbors(conn: &Connection, seed_ids: &[String]) -> Vec<(String, f64)> {
26    neighbors_weighted(conn, seed_ids, EdgeDirection::Both, None)
27}
28
29/// 1-hop neighbors from seed IDs with direction and relation filtering.
30///
31/// Generalization of [`graph_neighbors`]: returns `(neighbor_id, total_weight)`
32/// sorted by weight DESC. `dir` controls which edges are followed
33/// ([`EdgeDirection::Out`] = out-edges only, [`EdgeDirection::In`] = in-edges
34/// only, [`EdgeDirection::Both`] = both); `relation` restricts the walk to a
35/// single relation type (e.g. `"cites"`). Seed nodes are excluded from results.
36///
37/// At most [`MAX_SEED_IDS`] seeds are considered (keeps SQLite bind variables
38/// under the 999 limit).
39pub(crate) fn neighbors_weighted(
40    conn: &Connection,
41    seed_ids: &[String],
42    dir: EdgeDirection,
43    relation: Option<&str>,
44) -> Vec<(String, f64)> {
45    if seed_ids.is_empty() {
46        return vec![];
47    }
48    let seed_ids = if seed_ids.len() > MAX_SEED_IDS {
49        &seed_ids[..MAX_SEED_IDS]
50    } else {
51        seed_ids
52    };
53
54    let ph = seed_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
55    let rel_filter = relation.map(|_| " AND relation = ?").unwrap_or("");
56
57    let out_half = format!(
58        "SELECT target AS nb, SUM(weight) AS w FROM edges WHERE source IN ({ph}){rel_filter} GROUP BY target"
59    );
60    let in_half = format!(
61        "SELECT source AS nb, SUM(weight) AS w FROM edges WHERE target IN ({ph}){rel_filter} GROUP BY source"
62    );
63    let sql = match dir {
64        EdgeDirection::Out => out_half,
65        EdgeDirection::In => in_half,
66        EdgeDirection::Both => format!("{out_half} UNION ALL {in_half}"),
67    };
68
69    let mut stmt = match conn.prepare(&sql) {
70        Ok(s) => s,
71        Err(_) => return vec![],
72    };
73
74    // Bind: `halves` (1 for Out/In, 2 for Both) repetitions of (seeds [+ relation]).
75    let halves = if matches!(dir, EdgeDirection::Both) {
76        2
77    } else {
78        1
79    };
80    let mut binds: Vec<&str> = Vec::with_capacity(halves * (seed_ids.len() + 1));
81    for _ in 0..halves {
82        binds.extend(seed_ids.iter().map(String::as_str));
83        if let Some(r) = relation {
84            binds.push(r);
85        }
86    }
87
88    let rows: Vec<(String, f64)> = stmt
89        .query_map(rusqlite::params_from_iter(binds.iter()), |row| {
90            Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
91        })
92        .map(|rows| rows.flatten().collect())
93        .unwrap_or_default();
94
95    let seed_set: HashSet<&str> = seed_ids.iter().map(String::as_str).collect();
96    let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
97    for (nid, w) in rows {
98        if !seed_set.contains(nid.as_str()) {
99            *weights.entry(nid).or_default() += w;
100        }
101    }
102
103    let mut result: Vec<(String, f64)> = weights.into_iter().collect();
104    result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
105    result
106}
107
108/// BFS traversal from `start_id` via SQL recursive CTE.
109///
110/// Returns all reachable node IDs (excluding start), capped at 500.
111/// Follows edges in both directions.
112pub fn related_nodes(conn: &Connection, start_id: &str, depth: usize) -> Vec<String> {
113    let sql = "
114        WITH RECURSIVE bfs(node_id, lvl) AS (
115            SELECT target, 1 FROM edges WHERE source = ?1
116            UNION SELECT source, 1 FROM edges WHERE target = ?1
117            UNION SELECT e.target, bfs.lvl + 1 FROM edges e
118                JOIN bfs ON e.source = bfs.node_id
119                WHERE e.target != ?1 AND bfs.lvl < ?2
120            UNION SELECT e.source, bfs.lvl + 1 FROM edges e
121                JOIN bfs ON e.target = bfs.node_id
122                WHERE e.source != ?1 AND bfs.lvl < ?2
123        )
124        SELECT DISTINCT node_id FROM bfs
125        LIMIT 500
126    ";
127
128    conn.prepare(sql)
129        .and_then(|mut stmt| {
130            stmt.query_map(params![start_id, depth as i64], |row| {
131                row.get::<_, String>(0)
132            })
133            .map(|rows| rows.flatten().collect())
134        })
135        .unwrap_or_default()
136}
137
138/// Build a graph snapshot (node summaries + edges) from the database.
139pub fn build_graph(conn: &Connection) -> crate::error::Result<Graph> {
140    let ids = super::store::list_node_ids(conn)?;
141    let id_refs: Vec<&str> = ids.iter().map(String::as_str).collect();
142    let nodes: Vec<GraphNodeSummary> = read_nodes(conn, &id_refs)?
143        .into_iter()
144        .map(|node| GraphNodeSummary {
145            id: node.id,
146            title: node.title,
147            node_type: node.node_type,
148            tags: node.tags,
149            importance: node.importance,
150        })
151        .collect();
152    let edges: Vec<GraphEdge> = read_edges(conn, MAX_GRAPH_EDGES)?;
153    Ok(Graph { nodes, edges })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::graph::schema::init_graph_schema;
160    use crate::graph::store::append_edge;
161    use rusqlite::Connection;
162
163    fn mem_db() -> Connection {
164        let conn = Connection::open_in_memory().unwrap();
165        init_graph_schema(&conn).unwrap();
166        conn
167    }
168
169    fn insert_edge(conn: &Connection, id: &str, src: &str, tgt: &str) {
170        let e = GraphEdge {
171            id: id.to_string(),
172            source: src.to_string(),
173            target: tgt.to_string(),
174            relation: "related".to_string(),
175            weight: 1.0,
176            ts: "2026-01-01T00:00:00Z".to_string(),
177        };
178        append_edge(conn, &e).unwrap();
179    }
180
181    #[test]
182    fn neighbors_returns_direct_connections() {
183        let conn = mem_db();
184        insert_edge(&conn, "e1", "A", "B");
185        insert_edge(&conn, "e2", "A", "C");
186        insert_edge(&conn, "e3", "D", "A");
187
188        let mut result = graph_neighbors(&conn, &["A".to_string()]);
189        result.sort_by(|a, b| a.0.cmp(&b.0));
190        let ids: Vec<&str> = result.iter().map(|r| r.0.as_str()).collect();
191        assert!(ids.contains(&"B"));
192        assert!(ids.contains(&"C"));
193        assert!(ids.contains(&"D"));
194        assert!(!ids.contains(&"A"));
195    }
196
197    #[test]
198    fn neighbors_excludes_seeds() {
199        let conn = mem_db();
200        insert_edge(&conn, "e1", "A", "B");
201        insert_edge(&conn, "e2", "B", "C");
202
203        let result = graph_neighbors(&conn, &["A".to_string(), "B".to_string()]);
204        let ids: Vec<&str> = result.iter().map(|r| r.0.as_str()).collect();
205        assert!(ids.contains(&"C"));
206        assert!(!ids.contains(&"A"));
207        assert!(!ids.contains(&"B"));
208    }
209
210    #[test]
211    fn neighbors_empty_seeds() {
212        let conn = mem_db();
213        assert!(graph_neighbors(&conn, &[]).is_empty());
214    }
215
216    #[test]
217    fn related_nodes_recursive_bfs() {
218        let conn = mem_db();
219        insert_edge(&conn, "e1", "A", "B");
220        insert_edge(&conn, "e2", "B", "C");
221
222        let result = related_nodes(&conn, "A", 2);
223        assert!(result.contains(&"B".to_string()));
224        assert!(result.contains(&"C".to_string()));
225        assert!(!result.contains(&"A".to_string()));
226    }
227
228    #[test]
229    fn related_nodes_handles_cycles() {
230        let conn = mem_db();
231        insert_edge(&conn, "e1", "A", "B");
232        insert_edge(&conn, "e2", "B", "C");
233        insert_edge(&conn, "e3", "C", "A");
234
235        let result = related_nodes(&conn, "A", 3);
236        let unique: HashSet<_> = result.iter().collect();
237        assert_eq!(result.len(), unique.len(), "no duplicates in cycle");
238        assert!(!result.contains(&"A".to_string()));
239    }
240
241    #[test]
242    fn neighbor_weight_accumulation() {
243        let conn = mem_db();
244        insert_edge(&conn, "e1", "A", "C");
245        insert_edge(&conn, "e2", "B", "C");
246
247        let result = graph_neighbors(&conn, &["A".to_string(), "B".to_string()]);
248        let c_weight = result.iter().find(|(id, _)| id == "C").map(|(_, w)| *w);
249        assert_eq!(c_weight, Some(2.0));
250    }
251
252    #[test]
253    fn neighbors_weighted_out_direction_excludes_in_edges() {
254        let conn = mem_db();
255        insert_edge(&conn, "e1", "A", "B"); // A→B (out-edge of A)
256        insert_edge(&conn, "e2", "C", "A"); // C→A (in-edge of A)
257        let result = neighbors_weighted(&conn, &["A".to_string()], EdgeDirection::Out, None);
258        let ids: Vec<&str> = result.iter().map(|(id, _)| id.as_str()).collect();
259        assert!(ids.contains(&"B"));
260        assert!(!ids.contains(&"C"));
261    }
262
263    #[test]
264    fn neighbors_weighted_relation_filter_restricts_walk() {
265        let conn = mem_db();
266        append_edge(
267            &conn,
268            &GraphEdge {
269                id: "c1".into(),
270                source: "A".into(),
271                target: "B".into(),
272                relation: "cites".into(),
273                weight: 1.0,
274                ts: "t".into(),
275            },
276        )
277        .unwrap();
278        append_edge(
279            &conn,
280            &GraphEdge {
281                id: "s1".into(),
282                source: "A".into(),
283                target: "D".into(),
284                relation: "see_also".into(),
285                weight: 1.0,
286                ts: "t".into(),
287            },
288        )
289        .unwrap();
290        let only_cites = neighbors_weighted(
291            &conn,
292            &["A".to_string()],
293            EdgeDirection::Both,
294            Some("cites"),
295        );
296        let ids: Vec<&str> = only_cites.iter().map(|(id, _)| id.as_str()).collect();
297        assert_eq!(ids, vec!["B"]);
298    }
299}