Skip to main content

zeroclaw_memory/
knowledge_graph.rs

1//! Knowledge graph for capturing, organizing, and reusing expertise.
2//!
3//! SQLite-backed storage for knowledge nodes (patterns, decisions, lessons,
4//! experts, technologies) and directed edges (uses, replaces, extends,
5//! authored_by, applies_to). Supports full-text search, tag filtering,
6//! and relation traversal.
7
8use anyhow::Context;
9use chrono::{DateTime, Utc};
10use parking_lot::Mutex;
11use rusqlite::{Connection, params};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use uuid::Uuid;
16
17// ── Domain types ────────────────────────────────────────────────
18
19/// The kind of knowledge captured in a node.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum NodeType {
23    Pattern,
24    Decision,
25    Lesson,
26    Expert,
27    Technology,
28}
29
30impl NodeType {
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            Self::Pattern => "pattern",
34            Self::Decision => "decision",
35            Self::Lesson => "lesson",
36            Self::Expert => "expert",
37            Self::Technology => "technology",
38        }
39    }
40
41    pub fn parse(s: &str) -> anyhow::Result<Self> {
42        match s {
43            "pattern" => Ok(Self::Pattern),
44            "decision" => Ok(Self::Decision),
45            "lesson" => Ok(Self::Lesson),
46            "expert" => Ok(Self::Expert),
47            "technology" => Ok(Self::Technology),
48            other => anyhow::bail!("unknown node type: {other}"),
49        }
50    }
51}
52
53/// Directed relationship between two knowledge nodes.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Relation {
57    Uses,
58    Replaces,
59    Extends,
60    AuthoredBy,
61    AppliesTo,
62}
63
64impl Relation {
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            Self::Uses => "uses",
68            Self::Replaces => "replaces",
69            Self::Extends => "extends",
70            Self::AuthoredBy => "authored_by",
71            Self::AppliesTo => "applies_to",
72        }
73    }
74
75    pub fn parse(s: &str) -> anyhow::Result<Self> {
76        match s {
77            "uses" => Ok(Self::Uses),
78            "replaces" => Ok(Self::Replaces),
79            "extends" => Ok(Self::Extends),
80            "authored_by" => Ok(Self::AuthoredBy),
81            "applies_to" => Ok(Self::AppliesTo),
82            other => anyhow::bail!("unknown relation: {other}"),
83        }
84    }
85}
86
87/// A node in the knowledge graph.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct KnowledgeNode {
90    pub id: String,
91    pub node_type: NodeType,
92    pub title: String,
93    pub content: String,
94    pub tags: Vec<String>,
95    pub created_at: DateTime<Utc>,
96    pub updated_at: DateTime<Utc>,
97    pub source_project: Option<String>,
98}
99
100/// A directed edge in the knowledge graph.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct KnowledgeEdge {
103    pub from_id: String,
104    pub to_id: String,
105    pub relation: Relation,
106}
107
108/// A search result with relevance score.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct SearchResult {
111    pub node: KnowledgeNode,
112    pub score: f64,
113}
114
115/// Summary statistics for the knowledge graph.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct GraphStats {
118    pub total_nodes: usize,
119    pub total_edges: usize,
120    pub nodes_by_type: HashMap<String, usize>,
121    pub top_tags: Vec<(String, usize)>,
122}
123
124// ── Knowledge graph ─────────────────────────────────────────────
125
126/// SQLite-backed knowledge graph.
127pub struct KnowledgeGraph {
128    conn: Mutex<Connection>,
129    max_nodes: usize,
130}
131
132impl KnowledgeGraph {
133    /// Open (or create) a knowledge graph database at the given path.
134    pub fn new(db_path: &Path, max_nodes: usize) -> anyhow::Result<Self> {
135        if let Some(parent) = db_path.parent() {
136            std::fs::create_dir_all(parent)?;
137        }
138
139        let conn = Connection::open(db_path).context("failed to open knowledge graph database")?;
140
141        conn.execute_batch(
142            "PRAGMA journal_mode = WAL;
143             PRAGMA synchronous  = NORMAL;
144             PRAGMA foreign_keys = ON;",
145        )?;
146
147        conn.execute_batch(
148            "CREATE TABLE IF NOT EXISTS nodes (
149                id TEXT PRIMARY KEY,
150                node_type TEXT NOT NULL,
151                title TEXT NOT NULL,
152                content TEXT NOT NULL,
153                tags TEXT NOT NULL DEFAULT '',
154                created_at TEXT NOT NULL,
155                updated_at TEXT NOT NULL,
156                source_project TEXT
157            );
158
159            CREATE TABLE IF NOT EXISTS edges (
160                from_id TEXT NOT NULL,
161                to_id TEXT NOT NULL,
162                relation TEXT NOT NULL,
163                PRIMARY KEY (from_id, to_id, relation),
164                FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE,
165                FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE
166            );
167
168            CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
169                title, content, tags, content='nodes', content_rowid='rowid'
170            );
171
172            CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
173                INSERT INTO nodes_fts(rowid, title, content, tags)
174                VALUES (new.rowid, new.title, new.content, new.tags);
175            END;
176
177            CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
178                INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags)
179                VALUES ('delete', old.rowid, old.title, old.content, old.tags);
180            END;
181
182            CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
183                INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags)
184                VALUES ('delete', old.rowid, old.title, old.content, old.tags);
185                INSERT INTO nodes_fts(rowid, title, content, tags)
186                VALUES (new.rowid, new.title, new.content, new.tags);
187            END;
188
189            CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);
190            CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source_project);
191            CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
192            CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);",
193        )?;
194
195        Ok(Self {
196            conn: Mutex::new(conn),
197            max_nodes,
198        })
199    }
200
201    /// Add a node to the graph. Returns the generated node id.
202    pub fn add_node(
203        &self,
204        node_type: NodeType,
205        title: &str,
206        content: &str,
207        tags: &[String],
208        source_project: Option<&str>,
209    ) -> anyhow::Result<String> {
210        let conn = self.conn.lock();
211
212        // Enforce max_nodes limit.
213        let count: usize = conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
214        if count >= self.max_nodes {
215            anyhow::bail!(
216                "knowledge graph node limit reached ({}/{})",
217                count,
218                self.max_nodes
219            );
220        }
221
222        // Reject tags containing commas since comma is the separator in storage.
223        for tag in tags {
224            if tag.contains(',') {
225                anyhow::bail!(
226                    "tag '{}' contains a comma, which is used as the tag separator",
227                    tag
228                );
229            }
230        }
231
232        let id = Uuid::new_v4().to_string();
233        let now = Utc::now().to_rfc3339();
234        let tags_str = tags.join(",");
235
236        conn.execute(
237            "INSERT INTO nodes (id, node_type, title, content, tags, created_at, updated_at, source_project)
238             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
239            params![
240                id,
241                node_type.as_str(),
242                title,
243                content,
244                tags_str,
245                now,
246                now,
247                source_project,
248            ],
249        )?;
250
251        Ok(id)
252    }
253
254    /// Add a directed edge between two nodes.
255    pub fn add_edge(&self, from_id: &str, to_id: &str, relation: Relation) -> anyhow::Result<()> {
256        let conn = self.conn.lock();
257
258        // Verify both endpoints exist.
259        let exists = |id: &str| -> anyhow::Result<bool> {
260            let c: usize = conn.query_row(
261                "SELECT COUNT(*) FROM nodes WHERE id = ?1",
262                params![id],
263                |r| r.get(0),
264            )?;
265            Ok(c > 0)
266        };
267
268        if !exists(from_id)? {
269            anyhow::bail!("source node not found: {from_id}");
270        }
271        if !exists(to_id)? {
272            anyhow::bail!("target node not found: {to_id}");
273        }
274
275        conn.execute(
276            "INSERT OR IGNORE INTO edges (from_id, to_id, relation) VALUES (?1, ?2, ?3)",
277            params![from_id, to_id, relation.as_str()],
278        )?;
279
280        Ok(())
281    }
282
283    /// Retrieve a node by id.
284    pub fn get_node(&self, id: &str) -> anyhow::Result<Option<KnowledgeNode>> {
285        let conn = self.conn.lock();
286        let mut stmt = conn.prepare(
287            "SELECT id, node_type, title, content, tags, created_at, updated_at, source_project
288             FROM nodes WHERE id = ?1",
289        )?;
290
291        let mut rows = stmt.query(params![id])?;
292        match rows.next()? {
293            Some(row) => Ok(Some(row_to_node(row)?)),
294            None => Ok(None),
295        }
296    }
297
298    /// Query nodes by tags (all listed tags must be present).
299    pub fn query_by_tags(&self, tags: &[String]) -> anyhow::Result<Vec<KnowledgeNode>> {
300        let conn = self.conn.lock();
301        let mut stmt = conn.prepare(
302            "SELECT id, node_type, title, content, tags, created_at, updated_at, source_project
303             FROM nodes ORDER BY updated_at DESC",
304        )?;
305
306        let mut results = Vec::new();
307        let mut rows = stmt.query([])?;
308        while let Some(row) = rows.next()? {
309            let node = row_to_node(row)?;
310            if tags.iter().all(|t| node.tags.contains(t)) {
311                results.push(node);
312            }
313        }
314        Ok(results)
315    }
316
317    /// Full-text search across node titles, content, and tags.
318    pub fn query_by_similarity(
319        &self,
320        query: &str,
321        limit: usize,
322    ) -> anyhow::Result<Vec<SearchResult>> {
323        let conn = self.conn.lock();
324
325        // Sanitize FTS query: escape double quotes, wrap tokens in quotes.
326        let sanitized: String = query
327            .split_whitespace()
328            .map(|w| format!("\"{}\"", w.replace('"', "")))
329            .collect::<Vec<_>>()
330            .join(" ");
331
332        if sanitized.is_empty() {
333            return Ok(Vec::new());
334        }
335
336        let mut stmt = conn.prepare(
337            "SELECT n.id, n.node_type, n.title, n.content, n.tags,
338                    n.created_at, n.updated_at, n.source_project,
339                    rank
340             FROM nodes_fts f
341             JOIN nodes n ON n.rowid = f.rowid
342             WHERE nodes_fts MATCH ?1
343             ORDER BY rank
344             LIMIT ?2",
345        )?;
346
347        let mut results = Vec::new();
348        let mut rows = stmt.query(params![sanitized, limit as i64])?;
349        while let Some(row) = rows.next()? {
350            let node = row_to_node(row)?;
351            let rank: f64 = row.get(8)?;
352            results.push(SearchResult {
353                node,
354                score: -rank, // FTS5 rank is negative (lower = better), invert for intuitive scoring
355            });
356        }
357        Ok(results)
358    }
359
360    /// Find nodes directly related to the given node (both outbound and inbound edges).
361    pub fn find_related(&self, node_id: &str) -> anyhow::Result<Vec<(KnowledgeNode, Relation)>> {
362        let conn = self.conn.lock();
363        let mut stmt = conn.prepare(
364            "SELECT n.id, n.node_type, n.title, n.content, n.tags,
365                    n.created_at, n.updated_at, n.source_project,
366                    e.relation
367             FROM edges e
368             JOIN nodes n ON n.id = e.to_id
369             WHERE e.from_id = ?1
370             UNION ALL
371             SELECT n.id, n.node_type, n.title, n.content, n.tags,
372                    n.created_at, n.updated_at, n.source_project,
373                    e.relation
374             FROM edges e
375             JOIN nodes n ON n.id = e.from_id
376             WHERE e.to_id = ?1",
377        )?;
378
379        let mut results = Vec::new();
380        let mut rows = stmt.query(params![node_id])?;
381        while let Some(row) = rows.next()? {
382            let node = row_to_node(row)?;
383            let relation_str: String = row.get(8)?;
384            let relation = Relation::parse(&relation_str)?;
385            results.push((node, relation));
386        }
387        Ok(results)
388    }
389
390    /// Maximum allowed subgraph traversal depth.
391    const MAX_SUBGRAPH_DEPTH: usize = 100;
392
393    /// Extract a subgraph starting from `root_id` up to `depth` hops.
394    ///
395    /// `depth` must be between 1 and `MAX_SUBGRAPH_DEPTH` (100).
396    /// Uses a recursive CTE for efficient single-query bidirectional traversal.
397    pub fn get_subgraph(
398        &self,
399        root_id: &str,
400        depth: usize,
401    ) -> anyhow::Result<(Vec<KnowledgeNode>, Vec<KnowledgeEdge>)> {
402        if depth == 0 {
403            anyhow::bail!("subgraph depth must be greater than 0");
404        }
405        let depth = depth.min(Self::MAX_SUBGRAPH_DEPTH);
406        let conn = self.conn.lock();
407
408        // Collect reachable node IDs via recursive CTE (bidirectional traversal).
409        let mut node_stmt = conn.prepare(
410            "WITH RECURSIVE reachable(id, depth) AS (
411                SELECT ?1, 0
412                UNION
413                SELECT CASE WHEN e.from_id = r.id THEN e.to_id ELSE e.from_id END, r.depth + 1
414                FROM reachable r
415                JOIN edges e ON e.from_id = r.id OR e.to_id = r.id
416                WHERE r.depth < ?2
417             )
418             SELECT DISTINCT n.id, n.node_type, n.title, n.content, n.tags,
419                    n.created_at, n.updated_at, n.source_project
420             FROM reachable rc
421             JOIN nodes n ON n.id = rc.id",
422        )?;
423
424        let mut nodes = Vec::new();
425        let mut node_ids: HashSet<String> = HashSet::new();
426        let mut rows = node_stmt.query(params![root_id, depth as i64])?;
427        while let Some(row) = rows.next()? {
428            let node = row_to_node(row)?;
429            node_ids.insert(node.id.clone());
430            nodes.push(node);
431        }
432        drop(rows);
433
434        // Collect all edges where both endpoints are in the subgraph.
435        let mut edge_stmt = conn.prepare("SELECT from_id, to_id, relation FROM edges")?;
436
437        let mut edges = Vec::new();
438        let mut edge_rows = edge_stmt.query([])?;
439        while let Some(row) = edge_rows.next()? {
440            let from_id: String = row.get(0)?;
441            let to_id: String = row.get(1)?;
442            if node_ids.contains(&from_id) && node_ids.contains(&to_id) {
443                let relation_str: String = row.get(2)?;
444                let relation = Relation::parse(&relation_str)?;
445                edges.push(KnowledgeEdge {
446                    from_id,
447                    to_id,
448                    relation,
449                });
450            }
451        }
452
453        Ok((nodes, edges))
454    }
455
456    /// Find experts associated with the given tags via `authored_by` edges.
457    pub fn find_experts(&self, tags: &[String]) -> anyhow::Result<Vec<SearchResult>> {
458        // Find nodes matching the tags, then follow authored_by edges to experts.
459        let matching = self.query_by_tags(tags)?;
460        let mut expert_scores: HashMap<String, f64> = HashMap::new();
461
462        let conn = self.conn.lock();
463        for node in &matching {
464            let mut stmt = conn.prepare(
465                "SELECT to_id FROM edges WHERE from_id = ?1 AND relation = 'authored_by'",
466            )?;
467            let mut rows = stmt.query(params![node.id])?;
468            while let Some(row) = rows.next()? {
469                let expert_id: String = row.get(0)?;
470                *expert_scores.entry(expert_id).or_default() += 1.0;
471            }
472        }
473        drop(conn);
474
475        let mut results: Vec<SearchResult> = Vec::new();
476        for (eid, score) in expert_scores {
477            if let Some(node) = self.get_node(&eid)?
478                && node.node_type == NodeType::Expert
479            {
480                results.push(SearchResult { node, score });
481            }
482        }
483
484        results.sort_by(|a, b| {
485            b.score
486                .partial_cmp(&a.score)
487                .unwrap_or(std::cmp::Ordering::Equal)
488        });
489        Ok(results)
490    }
491
492    /// Return summary statistics for the graph.
493    pub fn stats(&self) -> anyhow::Result<GraphStats> {
494        let conn = self.conn.lock();
495
496        let total_nodes: usize = conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
497        let total_edges: usize = conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
498
499        let mut by_type = HashMap::new();
500        {
501            let mut stmt =
502                conn.prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type")?;
503            let mut rows = stmt.query([])?;
504            while let Some(row) = rows.next()? {
505                let t: String = row.get(0)?;
506                let c: usize = row.get(1)?;
507                by_type.insert(t, c);
508            }
509        }
510
511        // Top 10 tags by frequency.
512        let mut tag_counts: HashMap<String, usize> = HashMap::new();
513        {
514            let mut stmt = conn.prepare("SELECT tags FROM nodes WHERE tags != ''")?;
515            let mut rows = stmt.query([])?;
516            while let Some(row) = rows.next()? {
517                let tags_str: String = row.get(0)?;
518                for tag in tags_str.split(',') {
519                    let tag = tag.trim();
520                    if !tag.is_empty() {
521                        *tag_counts.entry(tag.to_string()).or_default() += 1;
522                    }
523                }
524            }
525        }
526        let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
527        top_tags.sort_by_key(|tag| std::cmp::Reverse(tag.1));
528        top_tags.truncate(10);
529
530        Ok(GraphStats {
531            total_nodes,
532            total_edges,
533            nodes_by_type: by_type,
534            top_tags,
535        })
536    }
537}
538
539/// Parse a database row into a `KnowledgeNode`.
540fn row_to_node(row: &rusqlite::Row<'_>) -> anyhow::Result<KnowledgeNode> {
541    let id: String = row.get(0)?;
542    let node_type_str: String = row.get(1)?;
543    let title: String = row.get(2)?;
544    let content: String = row.get(3)?;
545    let tags_str: String = row.get(4)?;
546    let created_at_str: String = row.get(5)?;
547    let updated_at_str: String = row.get(6)?;
548    let source_project: Option<String> = row.get(7)?;
549
550    let tags: Vec<String> = tags_str
551        .split(',')
552        .map(|s| s.trim().to_string())
553        .filter(|s| !s.is_empty())
554        .collect();
555
556    let created_at = DateTime::parse_from_rfc3339(&created_at_str)
557        .map(|dt| dt.with_timezone(&Utc))
558        .unwrap_or_else(|_| Utc::now());
559    let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
560        .map(|dt| dt.with_timezone(&Utc))
561        .unwrap_or_else(|_| Utc::now());
562
563    Ok(KnowledgeNode {
564        id,
565        node_type: NodeType::parse(&node_type_str)?,
566        title,
567        content,
568        tags,
569        created_at,
570        updated_at,
571        source_project,
572    })
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use tempfile::TempDir;
579
580    fn test_graph() -> (TempDir, KnowledgeGraph) {
581        let tmp = TempDir::new().unwrap();
582        let db_path = tmp.path().join("knowledge.db");
583        let graph = KnowledgeGraph::new(&db_path, 1000).unwrap();
584        (tmp, graph)
585    }
586
587    #[test]
588    fn add_node_returns_unique_id() {
589        let (_tmp, graph) = test_graph();
590        let id1 = graph
591            .add_node(
592                NodeType::Pattern,
593                "Caching",
594                "Use Redis for caching",
595                &["redis".into()],
596                None,
597            )
598            .unwrap();
599        let id2 = graph
600            .add_node(NodeType::Lesson, "Lesson A", "Content A", &[], None)
601            .unwrap();
602        assert_ne!(id1, id2);
603    }
604
605    #[test]
606    fn get_node_returns_stored_data() {
607        let (_tmp, graph) = test_graph();
608        let id = graph
609            .add_node(
610                NodeType::Decision,
611                "Use Postgres",
612                "Chose Postgres over MySQL",
613                &["database".into(), "postgres".into()],
614                Some("project_alpha"),
615            )
616            .unwrap();
617
618        let node = graph.get_node(&id).unwrap().unwrap();
619        assert_eq!(node.title, "Use Postgres");
620        assert_eq!(node.node_type, NodeType::Decision);
621        assert_eq!(node.tags, vec!["database", "postgres"]);
622        assert_eq!(node.source_project.as_deref(), Some("project_alpha"));
623    }
624
625    #[test]
626    fn get_node_missing_returns_none() {
627        let (_tmp, graph) = test_graph();
628        assert!(graph.get_node("nonexistent").unwrap().is_none());
629    }
630
631    #[test]
632    fn add_edge_creates_relationship() {
633        let (_tmp, graph) = test_graph();
634        let id1 = graph
635            .add_node(NodeType::Pattern, "P1", "Pattern one", &[], None)
636            .unwrap();
637        let id2 = graph
638            .add_node(NodeType::Technology, "T1", "Tech one", &[], None)
639            .unwrap();
640
641        graph.add_edge(&id1, &id2, Relation::Uses).unwrap();
642
643        // Outbound: from id1 → id2
644        let related = graph.find_related(&id1).unwrap();
645        assert!(
646            related
647                .iter()
648                .any(|(n, r)| n.id == id2 && *r == Relation::Uses)
649        );
650
651        // Inbound: id2 sees id1 via the same edge
652        let related = graph.find_related(&id2).unwrap();
653        assert!(
654            related
655                .iter()
656                .any(|(n, r)| n.id == id1 && *r == Relation::Uses)
657        );
658    }
659
660    #[test]
661    fn add_edge_rejects_missing_node() {
662        let (_tmp, graph) = test_graph();
663        let id = graph
664            .add_node(NodeType::Lesson, "L1", "Lesson", &[], None)
665            .unwrap();
666        let err = graph
667            .add_edge(&id, "nonexistent", Relation::Extends)
668            .unwrap_err();
669        assert!(err.to_string().contains("target node not found"));
670    }
671
672    #[test]
673    fn query_by_tags_filters_correctly() {
674        let (_tmp, graph) = test_graph();
675        graph
676            .add_node(
677                NodeType::Pattern,
678                "P1",
679                "Content",
680                &["rust".into(), "async".into()],
681                None,
682            )
683            .unwrap();
684        graph
685            .add_node(NodeType::Pattern, "P2", "Content", &["rust".into()], None)
686            .unwrap();
687        graph
688            .add_node(NodeType::Pattern, "P3", "Content", &["python".into()], None)
689            .unwrap();
690
691        let results = graph.query_by_tags(&["rust".into()]).unwrap();
692        assert_eq!(results.len(), 2);
693
694        let results = graph
695            .query_by_tags(&["rust".into(), "async".into()])
696            .unwrap();
697        assert_eq!(results.len(), 1);
698        assert_eq!(results[0].title, "P1");
699    }
700
701    #[test]
702    fn query_by_similarity_returns_ranked_results() {
703        let (_tmp, graph) = test_graph();
704        graph
705            .add_node(
706                NodeType::Decision,
707                "Choose Rust for performance",
708                "Rust gives memory safety and speed",
709                &["rust".into()],
710                None,
711            )
712            .unwrap();
713        graph
714            .add_node(
715                NodeType::Lesson,
716                "Python scaling issues",
717                "Python had GIL bottleneck",
718                &["python".into()],
719                None,
720            )
721            .unwrap();
722
723        let results = graph.query_by_similarity("Rust performance", 10).unwrap();
724        assert!(!results.is_empty());
725        assert!(results[0].score > 0.0);
726    }
727
728    #[test]
729    fn subgraph_traversal_collects_connected_nodes() {
730        let (_tmp, graph) = test_graph();
731        let a = graph
732            .add_node(NodeType::Pattern, "A", "Node A", &[], None)
733            .unwrap();
734        let b = graph
735            .add_node(NodeType::Pattern, "B", "Node B", &[], None)
736            .unwrap();
737        let c = graph
738            .add_node(NodeType::Pattern, "C", "Node C", &[], None)
739            .unwrap();
740        graph.add_edge(&a, &b, Relation::Extends).unwrap();
741        graph.add_edge(&b, &c, Relation::Uses).unwrap();
742
743        // Forward traversal from A reaches all 3 nodes.
744        let (nodes, edges) = graph.get_subgraph(&a, 2).unwrap();
745        assert_eq!(nodes.len(), 3);
746        assert_eq!(edges.len(), 2);
747
748        // Bidirectional: starting from C with depth 2 also reaches A.
749        let (nodes, edges) = graph.get_subgraph(&c, 2).unwrap();
750        assert_eq!(nodes.len(), 3);
751        assert_eq!(edges.len(), 2);
752    }
753
754    #[test]
755    fn expert_ranking_by_authored_contributions() {
756        let (_tmp, graph) = test_graph();
757        let expert = graph
758            .add_node(
759                NodeType::Expert,
760                "zeroclaw_user",
761                "Backend expert",
762                &[],
763                None,
764            )
765            .unwrap();
766        let p1 = graph
767            .add_node(
768                NodeType::Pattern,
769                "Cache pattern",
770                "Redis caching",
771                &["caching".into()],
772                None,
773            )
774            .unwrap();
775        let p2 = graph
776            .add_node(
777                NodeType::Pattern,
778                "Queue pattern",
779                "Message queue",
780                &["caching".into()],
781                None,
782            )
783            .unwrap();
784
785        graph.add_edge(&p1, &expert, Relation::AuthoredBy).unwrap();
786        graph.add_edge(&p2, &expert, Relation::AuthoredBy).unwrap();
787
788        let experts = graph.find_experts(&["caching".into()]).unwrap();
789        assert_eq!(experts.len(), 1);
790        assert_eq!(experts[0].node.title, "zeroclaw_user");
791        assert!((experts[0].score - 2.0).abs() < f64::EPSILON);
792    }
793
794    #[test]
795    fn max_nodes_limit_enforced() {
796        let tmp = TempDir::new().unwrap();
797        let db_path = tmp.path().join("knowledge.db");
798        let graph = KnowledgeGraph::new(&db_path, 2).unwrap();
799
800        graph
801            .add_node(NodeType::Lesson, "L1", "C1", &[], None)
802            .unwrap();
803        graph
804            .add_node(NodeType::Lesson, "L2", "C2", &[], None)
805            .unwrap();
806        let err = graph
807            .add_node(NodeType::Lesson, "L3", "C3", &[], None)
808            .unwrap_err();
809        assert!(err.to_string().contains("node limit reached"));
810    }
811
812    #[test]
813    fn stats_reports_correct_counts() {
814        let (_tmp, graph) = test_graph();
815        graph
816            .add_node(NodeType::Pattern, "P", "C", &["rust".into()], None)
817            .unwrap();
818        graph
819            .add_node(
820                NodeType::Lesson,
821                "L",
822                "C",
823                &["rust".into(), "async".into()],
824                None,
825            )
826            .unwrap();
827
828        let stats = graph.stats().unwrap();
829        assert_eq!(stats.total_nodes, 2);
830        assert_eq!(stats.nodes_by_type.get("pattern"), Some(&1));
831        assert_eq!(stats.nodes_by_type.get("lesson"), Some(&1));
832        assert!(!stats.top_tags.is_empty());
833    }
834
835    #[test]
836    fn node_type_roundtrip() {
837        for nt in &[
838            NodeType::Pattern,
839            NodeType::Decision,
840            NodeType::Lesson,
841            NodeType::Expert,
842            NodeType::Technology,
843        ] {
844            assert_eq!(&NodeType::parse(nt.as_str()).unwrap(), nt);
845        }
846    }
847
848    #[test]
849    fn relation_roundtrip() {
850        for r in &[
851            Relation::Uses,
852            Relation::Replaces,
853            Relation::Extends,
854            Relation::AuthoredBy,
855            Relation::AppliesTo,
856        ] {
857            assert_eq!(&Relation::parse(r.as_str()).unwrap(), r);
858        }
859    }
860}