Skip to main content

llm_kernel/graph/
backend.rs

1//! Backend-agnostic graph trait and SQLite implementation.
2//!
3//! [`GraphBackend`] is a sync, object-safe trait covering the primitive node/edge
4//! operations every graph backend must support. It deliberately exposes **no
5//! `rusqlite` types**, so a PostgreSQL or in-memory backend can implement it
6//! (see the v0.8.0 roadmap). [`SqliteGraph`] is the bundled implementation: it
7//! wraps a single mutex-guarded connection and delegates to the existing
8//! free-function graph API in [`crate::graph`].
9//!
10//! The existing free functions (`upsert_node(&conn, …)`, `search_nodes(&conn, …)`,
11//! …) are unchanged — callers that already own a `Connection` keep using them.
12//! [`SqliteGraph`] simply packages a connection behind the trait for users who
13//! want backend-agnostic graph access.
14
15use std::path::Path;
16use std::sync::Mutex;
17
18use rusqlite::Connection;
19
20use crate::error::Result;
21use crate::graph::recall::smart_recall;
22use crate::graph::schema::{init_graph_schema, migrate_graph, schema_version};
23use crate::graph::search::{query_nodes, search_nodes};
24use crate::graph::store::{
25    append_edge, delete_edge, delete_node, edges_for_node, remove_edges_for_node, upsert_node,
26};
27use crate::graph::traversal::related_nodes;
28use crate::graph::types::{EdgeDirection, GraphEdge, GraphNode, ScoredNode};
29
30/// Sync, object-safe trait for graph backends.
31///
32/// Methods cover node/edge CRUD, FTS search, filtered query, and schema
33/// migration. No method exposes `rusqlite` types, so the trait is implementable
34/// by any backend. `dyn GraphBackend` is usable.
35pub trait GraphBackend: Send + Sync {
36    /// Insert or replace a node.
37    fn upsert_node(&self, node: &GraphNode) -> Result<()>;
38    /// Read a single node by ID (`None` if absent).
39    fn read_node(&self, id: &str) -> Result<Option<GraphNode>>;
40    /// Delete a node by ID. Returns `true` if a row was removed.
41    fn delete_node(&self, id: &str) -> Result<bool>;
42    /// FTS5 full-text search, ranked by importance DESC.
43    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>>;
44    /// Dynamic filter by tag / node_type / project.
45    #[allow(clippy::too_many_arguments)]
46    fn query_nodes(
47        &self,
48        tag: Option<&str>,
49        node_type: Option<&str>,
50        project: Option<&str>,
51        limit: usize,
52    ) -> Result<Vec<GraphNode>>;
53    /// Composite recall — rank nodes by recency, importance, access, FTS, and
54    /// graph boost. The canonical high-level read path for "what's relevant".
55    fn smart_recall(
56        &self,
57        project: Option<&str>,
58        hint: Option<&str>,
59        limit: usize,
60    ) -> Result<Vec<ScoredNode>>;
61    /// BFS-traverse up to `depth` hops from `start_id`, returning related node
62    /// IDs (excluding the start).
63    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>>;
64    /// Append an edge (duplicates by edge ID are ignored).
65    fn append_edge(&self, edge: &GraphEdge) -> Result<()>;
66    /// Append many edges in one call.
67    ///
68    /// Duplicates by edge ID *or* by the `(source, target, relation)` unique
69    /// index are ignored. The default implementation loops [`Self::append_edge`];
70    /// backends with a batch path override it for throughput (citation graphs
71    /// built during indexing can reach hundreds of thousands of edges).
72    fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
73        for edge in edges {
74            self.append_edge(edge)?;
75        }
76        Ok(())
77    }
78    /// Read edges touching `node_id`, filtered by direction and an optional
79    /// relation. The default implementation reads both directions via
80    /// [`Self::edges_for_node`] and filters in Rust; backends with directional
81    /// indexes override for efficiency.
82    fn edges_for_node_dir(
83        &self,
84        node_id: &str,
85        dir: EdgeDirection,
86        relation: Option<&str>,
87    ) -> Result<Vec<GraphEdge>> {
88        Ok(self
89            .edges_for_node(node_id)?
90            .into_iter()
91            .filter(|e| match dir {
92                EdgeDirection::Out => e.source == node_id,
93                EdgeDirection::In => e.target == node_id,
94                EdgeDirection::Both => true,
95            })
96            .filter(|e| relation.is_none_or(|r| e.relation == r))
97            .collect())
98    }
99    /// 1-hop neighbors of `seed_ids` (weighted sum), restricted by direction
100    /// and an optional relation. Seed nodes are excluded. The default
101    /// implementation walks each seed via [`Self::edges_for_node_dir`].
102    fn neighbors_weighted(
103        &self,
104        seed_ids: &[String],
105        dir: EdgeDirection,
106        relation: Option<&str>,
107    ) -> Result<Vec<(String, f64)>> {
108        let mut weights: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
109        let seed_set: std::collections::HashSet<&str> =
110            seed_ids.iter().map(String::as_str).collect();
111        for seed in seed_ids {
112            for e in self.edges_for_node_dir(seed, dir, relation)? {
113                let other = if e.source == *seed {
114                    &e.target
115                } else {
116                    &e.source
117                };
118                if !seed_set.contains(other.as_str()) {
119                    *weights.entry(other.clone()).or_default() += e.weight;
120                }
121            }
122        }
123        let mut result: Vec<(String, f64)> = weights.into_iter().collect();
124        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
125        Ok(result)
126    }
127    /// BFS-traverse up to `depth` hops from `start_id`, returning related node
128    /// IDs (excluding the start), restricted by direction and an optional
129    /// relation. The default implementation hops via [`Self::neighbors_weighted`].
130    fn related_nodes_filtered(
131        &self,
132        start_id: &str,
133        depth: usize,
134        dir: EdgeDirection,
135        relation: Option<&str>,
136    ) -> Result<Vec<String>> {
137        if depth == 0 {
138            return Ok(vec![]);
139        }
140        let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
141        visited.insert(start_id.to_string());
142        let mut frontier: Vec<String> = vec![start_id.to_string()];
143        for _ in 0..depth {
144            let mut next: Vec<String> = Vec::new();
145            for node in &frontier {
146                for (nb, _) in self.neighbors_weighted(std::slice::from_ref(node), dir, relation)? {
147                    if visited.insert(nb.clone()) {
148                        next.push(nb);
149                    }
150                }
151            }
152            if next.is_empty() {
153                break;
154            }
155            frontier = next;
156        }
157        visited.remove(start_id);
158        Ok(visited.into_iter().collect())
159    }
160    /// Read edges where the given node is source or target.
161    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>>;
162    /// Delete an edge by ID. Returns `true` if a row was removed.
163    fn delete_edge(&self, id: &str) -> Result<bool>;
164    /// Remove every edge connected to a node.
165    fn remove_edges_for_node(&self, node_id: &str) -> Result<()>;
166
167    /// Recorded schema version for this backend.
168    fn current_version(&self) -> Result<u32>;
169    /// Apply pending migrations up to the backend's latest schema version.
170    /// Returns the resulting version.
171    fn migrate(&self) -> Result<u32>;
172}
173
174/// SQLite-backed [`GraphBackend`] over one mutex-guarded connection.
175///
176/// Opening applies the schema and runs any pending migrations, so a database
177/// created by an older `llm-kernel` is upgraded transparently on open.
178pub struct SqliteGraph {
179    conn: Mutex<Connection>,
180}
181
182impl SqliteGraph {
183    /// Open (or create) a graph database at `path`, applying schema + migrations.
184    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
185        let conn = open_with_schema(path.as_ref())?;
186        Ok(Self {
187            conn: Mutex::new(conn),
188        })
189    }
190
191    /// Create an in-memory graph (useful for tests and ephemeral stores).
192    pub fn open_in_memory() -> Result<Self> {
193        let conn = Connection::open_in_memory().map_err(store_err)?;
194        init_graph_schema(&conn)?;
195        let current = schema_version(&conn)?;
196        migrate_graph(&conn, current)?;
197        Ok(Self {
198            conn: Mutex::new(conn),
199        })
200    }
201
202    /// Lock helper: recover the guard even if a previous holder panicked.
203    fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
204        self.conn.lock().unwrap_or_else(|e| e.into_inner())
205    }
206
207    /// Record that a node's content was verified as of `now` (ISO 8601).
208    ///
209    /// Convenience wrapper over [`crate::graph::lifecycle::mark_verified`] —
210    /// the connection is owned by the graph, so callers don't need to open
211    /// their own.
212    pub fn mark_verified(&self, id: &str, now: &str) -> Result<bool> {
213        let c = self.lock();
214        crate::graph::lifecycle::mark_verified(&c, id, now)
215    }
216
217    /// Count nodes whose `valid_until` is set and earlier than `now` (ISO 8601).
218    ///
219    /// Convenience wrapper over [`crate::graph::lifecycle::count_expired_nodes`].
220    pub fn count_expired_nodes(&self, now: &str) -> Result<u64> {
221        let c = self.lock();
222        crate::graph::lifecycle::count_expired_nodes(&c, now)
223    }
224}
225
226/// Open a file-backed connection, apply the schema, then run pending migrations.
227fn open_with_schema(path: &Path) -> Result<Connection> {
228    let conn = Connection::open(path).map_err(store_err)?;
229    init_graph_schema(&conn)?;
230    let current = schema_version(&conn)?;
231    migrate_graph(&conn, current)?;
232    Ok(conn)
233}
234
235fn store_err(e: rusqlite::Error) -> crate::error::KernelError {
236    crate::error::KernelError::Store(e.to_string())
237}
238
239impl GraphBackend for SqliteGraph {
240    fn upsert_node(&self, node: &GraphNode) -> Result<()> {
241        let c = self.lock();
242        upsert_node(&c, node)
243    }
244
245    fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
246        let c = self.lock();
247        crate::graph::store::read_node(&c, id)
248    }
249
250    fn delete_node(&self, id: &str) -> Result<bool> {
251        let c = self.lock();
252        delete_node(&c, id)
253    }
254
255    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
256        let c = self.lock();
257        search_nodes(&c, query, limit)
258    }
259
260    fn query_nodes(
261        &self,
262        tag: Option<&str>,
263        node_type: Option<&str>,
264        project: Option<&str>,
265        limit: usize,
266    ) -> Result<Vec<GraphNode>> {
267        let c = self.lock();
268        query_nodes(&c, tag, node_type, project, limit)
269    }
270
271    fn smart_recall(
272        &self,
273        project: Option<&str>,
274        hint: Option<&str>,
275        limit: usize,
276    ) -> Result<Vec<ScoredNode>> {
277        let c = self.lock();
278        smart_recall(&c, project, hint, limit)
279    }
280
281    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
282        let c = self.lock();
283        Ok(related_nodes(&c, start_id, depth))
284    }
285
286    fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
287        let c = self.lock();
288        append_edge(&c, edge)
289    }
290
291    fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
292        let c = self.lock();
293        crate::graph::store::append_edges(&c, edges)
294    }
295
296    fn edges_for_node_dir(
297        &self,
298        node_id: &str,
299        dir: EdgeDirection,
300        relation: Option<&str>,
301    ) -> Result<Vec<GraphEdge>> {
302        let c = self.lock();
303        crate::graph::store::edges_for_node_dir(&c, node_id, dir, relation)
304    }
305
306    fn neighbors_weighted(
307        &self,
308        seed_ids: &[String],
309        dir: EdgeDirection,
310        relation: Option<&str>,
311    ) -> Result<Vec<(String, f64)>> {
312        let c = self.lock();
313        Ok(crate::graph::traversal::neighbors_weighted(
314            &c, seed_ids, dir, relation,
315        ))
316    }
317
318    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
319        let c = self.lock();
320        edges_for_node(&c, node_id)
321    }
322
323    fn delete_edge(&self, id: &str) -> Result<bool> {
324        let c = self.lock();
325        delete_edge(&c, id)
326    }
327
328    fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
329        let c = self.lock();
330        remove_edges_for_node(&c, node_id)
331    }
332
333    fn current_version(&self) -> Result<u32> {
334        let c = self.lock();
335        schema_version(&c)
336    }
337
338    fn migrate(&self) -> Result<u32> {
339        let c = self.lock();
340        let current = schema_version(&c)?;
341        migrate_graph(&c, current)
342    }
343}
344
345/// CJK-aware convenience methods (available with the `graph-cjk` feature).
346#[cfg(feature = "graph-cjk")]
347impl SqliteGraph {
348    /// CJK (or mixed) search via contiguous substring matching.
349    ///
350    /// Delegates to [`crate::graph::cjk::search_nodes_cjk`]; see its docs for
351    /// the matching semantics.
352    pub fn search_nodes_cjk(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
353        let c = self.lock();
354        crate::graph::cjk::search_nodes_cjk(&c, query, limit)
355    }
356
357    /// Segment a string for CJK tokenization (exposed for callers that want to
358    /// pre-process queries or inspect tokenization).
359    pub fn segment_cjk(text: &str) -> String {
360        crate::graph::cjk::segment_cjk(text)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn sample_node(id: &str) -> GraphNode {
369        GraphNode {
370            id: id.to_string(),
371            node_type: "concept".to_string(),
372            title: format!("Node {id}"),
373            body: "graph backend test body".to_string(),
374            tags: vec!["backend".to_string()],
375            projects: vec![],
376            agents: vec![],
377            created: "2026-01-01T00:00:00Z".to_string(),
378            updated: "2026-01-01T00:00:00Z".to_string(),
379            importance: 0.5,
380            access_count: 0,
381            accessed_at: String::new(),
382            ..Default::default()
383        }
384    }
385
386    /// AC4: a node round-trips through the trait, and the trait is usable as
387    /// `dyn GraphBackend` (object-safety) with no `rusqlite` in the surface.
388    #[test]
389    fn dyn_backend_round_trips_node() {
390        let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
391        assert!(backend.read_node("n1").unwrap().is_none());
392        backend.upsert_node(&sample_node("n1")).unwrap();
393        let loaded = backend.read_node("n1").unwrap().unwrap();
394        assert_eq!(loaded.title, "Node n1");
395        assert_eq!(loaded.tags, vec!["backend".to_string()]);
396        assert!(backend.delete_node("n1").unwrap());
397        assert!(backend.read_node("n1").unwrap().is_none());
398    }
399
400    /// Temporal-validity wrappers reachable without owning a connection.
401    #[test]
402    fn mark_verified_and_count_expired_wrappers() {
403        let backend = SqliteGraph::open_in_memory().unwrap();
404        backend.upsert_node(&sample_node("n1")).unwrap();
405        assert!(backend.mark_verified("n1", "2026-08-18T00:00:00Z").unwrap());
406        let node = backend.read_node("n1").unwrap().unwrap();
407        assert_eq!(node.last_verified, "2026-08-18T00:00:00Z");
408        assert_eq!(
409            backend.count_expired_nodes("2026-08-18T00:00:00Z").unwrap(),
410            0
411        );
412    }
413
414    /// AC5: a fresh backend reports the current schema version.
415    #[test]
416    fn fresh_backend_reports_current_version() {
417        let backend = SqliteGraph::open_in_memory().unwrap();
418        assert_eq!(
419            backend.current_version().unwrap(),
420            crate::graph::schema::GRAPH_SCHEMA_VERSION
421        );
422    }
423
424    /// AC5: search through the trait finds an inserted node by title.
425    #[test]
426    fn backend_search_finds_node() {
427        let backend = SqliteGraph::open_in_memory().unwrap();
428        backend.upsert_node(&sample_node("rust")).unwrap();
429        let hits = backend.search_nodes("graph backend", 10).unwrap();
430        assert_eq!(hits.len(), 1);
431        assert_eq!(hits[0].id, "rust");
432    }
433
434    /// The composite recall path is reachable through the trait.
435    #[test]
436    fn backend_smart_recall_finds_relevant() {
437        let backend = SqliteGraph::open_in_memory().unwrap();
438        let mut n = sample_node("rust");
439        n.body = "rust ownership borrow checker".to_string();
440        backend.upsert_node(&n).unwrap();
441        let recalled = backend.smart_recall(None, Some("ownership"), 5).unwrap();
442        assert!(recalled.iter().any(|s| s.node.id == "rust"));
443    }
444
445    /// The composite traversal path is reachable through the trait.
446    #[test]
447    fn backend_related_nodes_traverses_edges() {
448        let backend = SqliteGraph::open_in_memory().unwrap();
449        backend.upsert_node(&sample_node("a")).unwrap();
450        backend.upsert_node(&sample_node("b")).unwrap();
451        backend
452            .append_edge(&GraphEdge {
453                id: "e1".into(),
454                source: "a".into(),
455                target: "b".into(),
456                relation: "related".into(),
457                weight: 1.0,
458                ts: "2026-01-01T00:00:00Z".into(),
459            })
460            .unwrap();
461        let related = backend.related_nodes("a", 2).unwrap();
462        assert!(related.contains(&"b".to_string()));
463    }
464
465    /// Batch edge append + directional/relation-filtered lookups through the
466    /// trait (object-safe path), including the BFS `related_nodes_filtered`.
467    #[test]
468    fn dyn_backend_batch_and_filtered_edges() {
469        let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
470        backend
471            .append_edges(&[
472                GraphEdge {
473                    id: "e1".into(),
474                    source: "a".into(),
475                    target: "b".into(),
476                    relation: "cites".into(),
477                    weight: 1.0,
478                    ts: "t".into(),
479                },
480                GraphEdge {
481                    id: "e2".into(),
482                    source: "c".into(),
483                    target: "a".into(),
484                    relation: "cites".into(),
485                    weight: 1.0,
486                    ts: "t".into(),
487                },
488                GraphEdge {
489                    id: "e3".into(),
490                    source: "a".into(),
491                    target: "d".into(),
492                    relation: "see_also".into(),
493                    weight: 1.0,
494                    ts: "t".into(),
495                },
496            ])
497            .unwrap();
498        // Out-edges of `a`: b, d.
499        assert_eq!(
500            backend
501                .edges_for_node_dir("a", EdgeDirection::Out, None)
502                .unwrap()
503                .len(),
504            2
505        );
506        // Out-only neighbors of `a` filtered to `cites`: only b (not c, which is in-edge).
507        let nbs = backend
508            .neighbors_weighted(&["a".to_string()], EdgeDirection::Out, Some("cites"))
509            .unwrap();
510        let ids: Vec<&str> = nbs.iter().map(|(id, _)| id.as_str()).collect();
511        assert_eq!(ids, vec!["b"]);
512        // BFS out-only depth 2 from `a`: reaches b and d.
513        let rel = backend
514            .related_nodes_filtered("a", 2, EdgeDirection::Out, None)
515            .unwrap();
516        assert!(rel.contains(&"b".to_string()));
517        assert!(rel.contains(&"d".to_string()));
518    }
519}