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    /// Run `f` against the underlying connection inside one transaction.
226    ///
227    /// Commits when `f` returns `Ok`, rolls back on `Err` — so multi-step
228    /// sequences (e.g. delete-then-insert edge replacement) cannot leave a
229    /// half-applied state. `f` receives `&Connection`: the transaction derefs
230    /// to it, so the free functions in [`crate::graph::store`] work as-is.
231    ///
232    /// Do **not** call other `SqliteGraph` methods on the same instance inside
233    /// `f` — they re-acquire the connection lock and will deadlock. Use only
234    /// the free functions on the passed connection. Similarly, avoid the
235    /// free functions that open their own transaction inside `f` — notably
236    /// [`crate::graph::store::append_edges`] and
237    /// [`crate::graph::store::delete_node`] — they fail with "cannot start a
238    /// transaction within a transaction". Their single-item counterparts
239    /// ([`crate::graph::store::append_edge`], plain `DELETE`) are safe.
240    pub fn with_tx(&self, f: impl FnOnce(&Connection) -> Result<()>) -> Result<()> {
241        let conn = self.lock();
242        let tx = conn.unchecked_transaction().map_err(store_err)?;
243        f(&tx)?;
244        tx.commit().map_err(store_err)
245    }
246}
247
248/// Open a file-backed connection, apply the schema, then run pending migrations.
249fn open_with_schema(path: &Path) -> Result<Connection> {
250    let conn = Connection::open(path).map_err(store_err)?;
251    init_graph_schema(&conn)?;
252    let current = schema_version(&conn)?;
253    migrate_graph(&conn, current)?;
254    Ok(conn)
255}
256
257fn store_err(e: rusqlite::Error) -> crate::error::KernelError {
258    crate::error::KernelError::Store(e.to_string())
259}
260
261impl GraphBackend for SqliteGraph {
262    fn upsert_node(&self, node: &GraphNode) -> Result<()> {
263        let c = self.lock();
264        upsert_node(&c, node)
265    }
266
267    fn read_node(&self, id: &str) -> Result<Option<GraphNode>> {
268        let c = self.lock();
269        crate::graph::store::read_node(&c, id)
270    }
271
272    fn delete_node(&self, id: &str) -> Result<bool> {
273        let c = self.lock();
274        delete_node(&c, id)
275    }
276
277    fn search_nodes(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
278        let c = self.lock();
279        search_nodes(&c, query, limit)
280    }
281
282    fn query_nodes(
283        &self,
284        tag: Option<&str>,
285        node_type: Option<&str>,
286        project: Option<&str>,
287        limit: usize,
288    ) -> Result<Vec<GraphNode>> {
289        let c = self.lock();
290        query_nodes(&c, tag, node_type, project, limit)
291    }
292
293    fn smart_recall(
294        &self,
295        project: Option<&str>,
296        hint: Option<&str>,
297        limit: usize,
298    ) -> Result<Vec<ScoredNode>> {
299        let c = self.lock();
300        smart_recall(&c, project, hint, limit)
301    }
302
303    fn related_nodes(&self, start_id: &str, depth: usize) -> Result<Vec<String>> {
304        let c = self.lock();
305        Ok(related_nodes(&c, start_id, depth))
306    }
307
308    fn append_edge(&self, edge: &GraphEdge) -> Result<()> {
309        let c = self.lock();
310        append_edge(&c, edge)
311    }
312
313    fn append_edges(&self, edges: &[GraphEdge]) -> Result<()> {
314        let c = self.lock();
315        crate::graph::store::append_edges(&c, edges)
316    }
317
318    fn edges_for_node_dir(
319        &self,
320        node_id: &str,
321        dir: EdgeDirection,
322        relation: Option<&str>,
323    ) -> Result<Vec<GraphEdge>> {
324        let c = self.lock();
325        crate::graph::store::edges_for_node_dir(&c, node_id, dir, relation)
326    }
327
328    fn neighbors_weighted(
329        &self,
330        seed_ids: &[String],
331        dir: EdgeDirection,
332        relation: Option<&str>,
333    ) -> Result<Vec<(String, f64)>> {
334        let c = self.lock();
335        Ok(crate::graph::traversal::neighbors_weighted(
336            &c, seed_ids, dir, relation,
337        ))
338    }
339
340    fn edges_for_node(&self, node_id: &str) -> Result<Vec<GraphEdge>> {
341        let c = self.lock();
342        edges_for_node(&c, node_id)
343    }
344
345    fn delete_edge(&self, id: &str) -> Result<bool> {
346        let c = self.lock();
347        delete_edge(&c, id)
348    }
349
350    fn remove_edges_for_node(&self, node_id: &str) -> Result<()> {
351        let c = self.lock();
352        remove_edges_for_node(&c, node_id)
353    }
354
355    fn current_version(&self) -> Result<u32> {
356        let c = self.lock();
357        schema_version(&c)
358    }
359
360    fn migrate(&self) -> Result<u32> {
361        let c = self.lock();
362        let current = schema_version(&c)?;
363        migrate_graph(&c, current)
364    }
365}
366
367/// CJK-aware convenience methods (available with the `graph-cjk` feature).
368#[cfg(feature = "graph-cjk")]
369impl SqliteGraph {
370    /// CJK (or mixed) search via contiguous substring matching.
371    ///
372    /// Delegates to [`crate::graph::cjk::search_nodes_cjk`]; see its docs for
373    /// the matching semantics.
374    pub fn search_nodes_cjk(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
375        let c = self.lock();
376        crate::graph::cjk::search_nodes_cjk(&c, query, limit)
377    }
378
379    /// Segment a string for CJK tokenization (exposed for callers that want to
380    /// pre-process queries or inspect tokenization).
381    pub fn segment_cjk(text: &str) -> String {
382        crate::graph::cjk::segment_cjk(text)
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn sample_node(id: &str) -> GraphNode {
391        GraphNode {
392            id: id.to_string(),
393            node_type: "concept".to_string(),
394            title: format!("Node {id}"),
395            body: "graph backend test body".to_string(),
396            tags: vec!["backend".to_string()],
397            projects: vec![],
398            agents: vec![],
399            created: "2026-01-01T00:00:00Z".to_string(),
400            updated: "2026-01-01T00:00:00Z".to_string(),
401            importance: 0.5,
402            access_count: 0,
403            accessed_at: String::new(),
404            ..Default::default()
405        }
406    }
407
408    /// AC4: a node round-trips through the trait, and the trait is usable as
409    /// `dyn GraphBackend` (object-safety) with no `rusqlite` in the surface.
410    #[test]
411    fn dyn_backend_round_trips_node() {
412        let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
413        assert!(backend.read_node("n1").unwrap().is_none());
414        backend.upsert_node(&sample_node("n1")).unwrap();
415        let loaded = backend.read_node("n1").unwrap().unwrap();
416        assert_eq!(loaded.title, "Node n1");
417        assert_eq!(loaded.tags, vec!["backend".to_string()]);
418        assert!(backend.delete_node("n1").unwrap());
419        assert!(backend.read_node("n1").unwrap().is_none());
420    }
421
422    /// Temporal-validity wrappers reachable without owning a connection.
423    #[test]
424    fn mark_verified_and_count_expired_wrappers() {
425        let backend = SqliteGraph::open_in_memory().unwrap();
426        backend.upsert_node(&sample_node("n1")).unwrap();
427        assert!(backend.mark_verified("n1", "2026-08-18T00:00:00Z").unwrap());
428        let node = backend.read_node("n1").unwrap().unwrap();
429        assert_eq!(node.last_verified, "2026-08-18T00:00:00Z");
430        assert_eq!(
431            backend.count_expired_nodes("2026-08-18T00:00:00Z").unwrap(),
432            0
433        );
434    }
435
436    /// `with_tx` commits on Ok — both statements are visible afterwards.
437    #[test]
438    fn with_tx_commits_on_ok() {
439        let backend = SqliteGraph::open_in_memory().unwrap();
440        backend.upsert_node(&sample_node("a")).unwrap();
441        backend.upsert_node(&sample_node("b")).unwrap();
442        backend
443            .with_tx(|conn| {
444                crate::graph::store::remove_edges_for_node(conn, "a")?;
445                crate::graph::store::append_edge(
446                    conn,
447                    &GraphEdge {
448                        id: "e1".into(),
449                        source: "a".into(),
450                        target: "b".into(),
451                        relation: "related".into(),
452                        weight: 1.0,
453                        ts: "2026-01-01T00:00:00Z".into(),
454                    },
455                )
456            })
457            .unwrap();
458        let edges = backend.edges_for_node("a").unwrap();
459        assert_eq!(edges.len(), 1);
460    }
461
462    /// `with_tx` rolls back on Err — the delete is undone, nothing is lost.
463    #[test]
464    fn with_tx_rolls_back_on_err() {
465        let backend = SqliteGraph::open_in_memory().unwrap();
466        backend.upsert_node(&sample_node("a")).unwrap();
467        backend.upsert_node(&sample_node("b")).unwrap();
468        backend
469            .append_edge(&GraphEdge {
470                id: "e0".into(),
471                source: "a".into(),
472                target: "b".into(),
473                relation: "related".into(),
474                weight: 1.0,
475                ts: "2026-01-01T00:00:00Z".into(),
476            })
477            .unwrap();
478
479        let result = backend.with_tx(|conn| {
480            crate::graph::store::remove_edges_for_node(conn, "a")?;
481            // Simulate a failure after the delete.
482            Err(crate::error::KernelError::Store("boom".into()))
483        });
484        assert!(result.is_err());
485        // The original edge survived — rollback undid the delete.
486        let edges = backend.edges_for_node("a").unwrap();
487        assert_eq!(edges.len(), 1, "edge lost mid-transaction: {edges:?}");
488    }
489
490    /// AC5: a fresh backend reports the current schema version.
491    #[test]
492    fn fresh_backend_reports_current_version() {
493        let backend = SqliteGraph::open_in_memory().unwrap();
494        assert_eq!(
495            backend.current_version().unwrap(),
496            crate::graph::schema::GRAPH_SCHEMA_VERSION
497        );
498    }
499
500    /// AC5: search through the trait finds an inserted node by title.
501    #[test]
502    fn backend_search_finds_node() {
503        let backend = SqliteGraph::open_in_memory().unwrap();
504        backend.upsert_node(&sample_node("rust")).unwrap();
505        let hits = backend.search_nodes("graph backend", 10).unwrap();
506        assert_eq!(hits.len(), 1);
507        assert_eq!(hits[0].id, "rust");
508    }
509
510    /// The composite recall path is reachable through the trait.
511    #[test]
512    fn backend_smart_recall_finds_relevant() {
513        let backend = SqliteGraph::open_in_memory().unwrap();
514        let mut n = sample_node("rust");
515        n.body = "rust ownership borrow checker".to_string();
516        backend.upsert_node(&n).unwrap();
517        let recalled = backend.smart_recall(None, Some("ownership"), 5).unwrap();
518        assert!(recalled.iter().any(|s| s.node.id == "rust"));
519    }
520
521    /// The composite traversal path is reachable through the trait.
522    #[test]
523    fn backend_related_nodes_traverses_edges() {
524        let backend = SqliteGraph::open_in_memory().unwrap();
525        backend.upsert_node(&sample_node("a")).unwrap();
526        backend.upsert_node(&sample_node("b")).unwrap();
527        backend
528            .append_edge(&GraphEdge {
529                id: "e1".into(),
530                source: "a".into(),
531                target: "b".into(),
532                relation: "related".into(),
533                weight: 1.0,
534                ts: "2026-01-01T00:00:00Z".into(),
535            })
536            .unwrap();
537        let related = backend.related_nodes("a", 2).unwrap();
538        assert!(related.contains(&"b".to_string()));
539    }
540
541    /// Batch edge append + directional/relation-filtered lookups through the
542    /// trait (object-safe path), including the BFS `related_nodes_filtered`.
543    #[test]
544    fn dyn_backend_batch_and_filtered_edges() {
545        let backend: Box<dyn GraphBackend> = Box::new(SqliteGraph::open_in_memory().unwrap());
546        backend
547            .append_edges(&[
548                GraphEdge {
549                    id: "e1".into(),
550                    source: "a".into(),
551                    target: "b".into(),
552                    relation: "cites".into(),
553                    weight: 1.0,
554                    ts: "t".into(),
555                },
556                GraphEdge {
557                    id: "e2".into(),
558                    source: "c".into(),
559                    target: "a".into(),
560                    relation: "cites".into(),
561                    weight: 1.0,
562                    ts: "t".into(),
563                },
564                GraphEdge {
565                    id: "e3".into(),
566                    source: "a".into(),
567                    target: "d".into(),
568                    relation: "see_also".into(),
569                    weight: 1.0,
570                    ts: "t".into(),
571                },
572            ])
573            .unwrap();
574        // Out-edges of `a`: b, d.
575        assert_eq!(
576            backend
577                .edges_for_node_dir("a", EdgeDirection::Out, None)
578                .unwrap()
579                .len(),
580            2
581        );
582        // Out-only neighbors of `a` filtered to `cites`: only b (not c, which is in-edge).
583        let nbs = backend
584            .neighbors_weighted(&["a".to_string()], EdgeDirection::Out, Some("cites"))
585            .unwrap();
586        let ids: Vec<&str> = nbs.iter().map(|(id, _)| id.as_str()).collect();
587        assert_eq!(ids, vec!["b"]);
588        // BFS out-only depth 2 from `a`: reaches b and d.
589        let rel = backend
590            .related_nodes_filtered("a", 2, EdgeDirection::Out, None)
591            .unwrap();
592        assert!(rel.contains(&"b".to_string()));
593        assert!(rel.contains(&"d".to_string()));
594    }
595}