Skip to main content

llm_kernel/graph/
types.rs

1//! Core types for the knowledge graph.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Default importance for new nodes.
7pub fn default_importance() -> f64 {
8    0.5
9}
10
11/// A node in the knowledge graph.
12///
13/// Represents a discrete piece of knowledge — a decision, concept, pattern, etc.
14/// Stored as a single row in the `nodes` SQLite table.
15///
16/// Derives `Default` so callers can future-proof field additions with struct
17/// update syntax: `GraphNode { id, node_type, ..Default::default() }`.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct GraphNode {
20    /// Unique node identifier (UUID).
21    pub id: String,
22    /// Node type (e.g. "decision", "concept", "pattern", "error", "session").
23    #[serde(rename = "type")]
24    pub node_type: String,
25    /// Short title summarizing the node's content.
26    pub title: String,
27    /// Full text body of the node.
28    #[serde(default)]
29    pub body: String,
30    /// Classification tags for filtering and search.
31    #[serde(default)]
32    pub tags: Vec<String>,
33    /// Projects this node belongs to.
34    #[serde(default)]
35    pub projects: Vec<String>,
36    /// Agents that contributed to or own this node.
37    #[serde(default)]
38    pub agents: Vec<String>,
39    /// ISO 8601 creation timestamp.
40    pub created: String,
41    /// ISO 8601 last-updated timestamp.
42    pub updated: String,
43    /// Importance score (0.0–1.0). Higher = more valuable for recall.
44    #[serde(default = "default_importance")]
45    pub importance: f64,
46    /// How many times this node has been retrieved via recall/search.
47    #[serde(default)]
48    pub access_count: i64,
49    /// Last time this node was accessed (ISO 8601).
50    #[serde(default)]
51    pub accessed_at: String,
52    /// Timestamp after which this node is considered expired (ISO 8601).
53    /// Empty string means no expiry (the node never goes stale by date).
54    #[serde(default)]
55    pub valid_until: String,
56    /// Last time a verification pass confirmed this node's content (ISO 8601).
57    /// Empty string means never verified.
58    #[serde(default)]
59    pub last_verified: String,
60}
61
62/// A directed, weighted edge between two nodes.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct GraphEdge {
65    /// Unique edge identifier (UUID).
66    pub id: String,
67    /// Source node ID.
68    pub source: String,
69    /// Target node ID.
70    pub target: String,
71    /// Relationship type (e.g. "related", "solves", "derived_from").
72    pub relation: String,
73    /// Edge weight (0.0–1.0); higher values indicate stronger relationships.
74    pub weight: f64,
75    /// ISO 8601 creation timestamp.
76    pub ts: String,
77}
78
79/// Direction filter for directed edge lookups.
80///
81/// Historical edge queries were bidirectional (`source = ? OR target = ?`).
82/// `EdgeDirection` restricts a lookup to outgoing or incoming edges only —
83/// required for inherently directed graphs such as legal citation networks
84/// (`A` cites `B`) or document wikilinks/backlinks.
85///
86/// [`Both`](Self::Both) is the default and preserves the historical behavior.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub enum EdgeDirection {
89    /// Out-edges only: the node is the edge `source`.
90    Out,
91    /// In-edges only: the node is the edge `target`.
92    In,
93    /// Both directions (default; preserves the historical bidirectional behavior).
94    #[default]
95    Both,
96}
97
98/// Summary of a node for serialization (web viewer, API responses).
99/// Omits body and metadata fields for compact payloads.
100#[non_exhaustive]
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct GraphNodeSummary {
103    /// Unique node identifier.
104    pub id: String,
105    /// Short title.
106    pub title: String,
107    /// Node type.
108    #[serde(rename = "type")]
109    pub node_type: String,
110    /// Classification tags.
111    #[serde(default)]
112    pub tags: Vec<String>,
113    /// Importance score (0.0–1.0).
114    #[serde(default = "default_importance")]
115    pub importance: f64,
116}
117
118/// A graph snapshot containing nodes (summaries) and edges.
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct Graph {
121    /// All nodes in the snapshot.
122    pub nodes: Vec<GraphNodeSummary>,
123    /// All edges in the snapshot.
124    pub edges: Vec<GraphEdge>,
125}
126
127/// A node scored by relevance for recall ranking.
128#[derive(Debug, Clone)]
129pub struct ScoredNode {
130    /// The graph node.
131    pub node: GraphNode,
132    /// Composite relevance score.
133    pub score: f64,
134}
135
136/// Aggregate statistics about the knowledge graph.
137#[non_exhaustive]
138#[derive(Debug, Clone, Serialize, Deserialize, Default)]
139pub struct GraphStats {
140    /// Total number of nodes.
141    pub total_nodes: i64,
142    /// Total number of edges.
143    pub total_edges: i64,
144    /// Mean importance score across all nodes.
145    pub avg_importance: f64,
146    /// Node count broken down by node type.
147    pub by_type: HashMap<String, i64>,
148}
149
150// ── CSV helpers (tags, projects, agents stored as comma-separated strings) ──
151
152pub(crate) fn join_csv(v: &[String]) -> String {
153    v.join(",")
154}
155
156pub(crate) fn split_csv(s: &str) -> Vec<String> {
157    s.split(',')
158        .map(|x| x.trim().to_string())
159        .filter(|x| !x.is_empty())
160        .collect()
161}
162
163/// Escape SQL LIKE wildcards (`%`, `_`, `\`) in a bound value.
164///
165/// Used with `LIKE '%,' || ? ESCAPE '\' || ',%'` patterns to prevent
166/// project names or tags containing `%` or `_` from matching unintended rows.
167pub(crate) fn escape_like(value: &str) -> String {
168    let mut out = String::with_capacity(value.len());
169    for ch in value.chars() {
170        match ch {
171            '%' | '_' | '\\' => {
172                out.push('\\');
173                out.push(ch);
174            }
175            _ => out.push(ch),
176        }
177    }
178    out
179}
180
181/// Validate a UUID v4 string (strict format: `xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx`).
182///
183/// Pure byte inspection — zero dependencies. Useful for validating node and edge IDs
184/// before writing to the graph.
185pub fn validate_uuid(id: &str) -> bool {
186    let b = id.as_bytes();
187    b.len() == 36
188        && b[8] == b'-'
189        && b[13] == b'-'
190        && b[18] == b'-'
191        && b[23] == b'-'
192        && b[14] == b'4'
193        && matches!(b[19], b'8'..=b'9' | b'a'..=b'b' | b'A'..=b'B')
194        && b.iter()
195            .enumerate()
196            .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
197}
198
199// ── Row mapping ───────────────────────────────────────
200
201/// Standard SELECT columns for node queries.
202pub(crate) const NODE_COLUMNS: &str = "id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at, valid_until, last_verified";
203
204/// Same columns but table-prefixed for JOIN queries.
205pub(crate) const NODE_COLUMNS_PREFIXED: &str = "id, n.type, n.title, n.tags, n.projects, n.agents, n.created, n.updated, n.body, n.importance, n.access_count, n.accessed_at, n.valid_until, n.last_verified";
206
207pub(crate) fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<GraphNode> {
208    let tags: String = row.get(3)?;
209    let projects: String = row.get(4)?;
210    let agents: String = row.get(5)?;
211    Ok(GraphNode {
212        id: row.get(0)?,
213        node_type: row.get(1)?,
214        title: row.get(2)?,
215        tags: split_csv(&tags),
216        projects: split_csv(&projects),
217        agents: split_csv(&agents),
218        created: row.get(6)?,
219        updated: row.get(7)?,
220        body: row.get(8)?,
221        importance: row.get(9).unwrap_or(0.5),
222        access_count: row.get::<_, i64>(10).unwrap_or(0),
223        accessed_at: row.get(11).unwrap_or_default(),
224        valid_until: row.get(12).unwrap_or_default(),
225        last_verified: row.get(13).unwrap_or_default(),
226    })
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn escape_like_escapes_percent() {
235        assert_eq!(escape_like("100%"), r"100\%");
236    }
237
238    #[test]
239    fn escape_like_escapes_underscore() {
240        assert_eq!(escape_like("a_b"), r"a\_b");
241    }
242
243    #[test]
244    fn escape_like_passthrough_normal() {
245        assert_eq!(escape_like("hello"), "hello");
246    }
247
248    #[test]
249    fn escape_like_escapes_backslash() {
250        assert_eq!(escape_like(r"\"), r"\\");
251    }
252
253    #[test]
254    fn escape_like_empty() {
255        assert_eq!(escape_like(""), "");
256    }
257
258    #[test]
259    fn validate_uuid_accepts_valid_v4() {
260        assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000"));
261        assert!(validate_uuid("00000000-0000-4000-8000-000000000000"));
262        assert!(validate_uuid("ffffffff-ffff-4fff-bfff-ffffffffffff"));
263    }
264
265    #[test]
266    fn validate_uuid_rejects_wrong_version() {
267        // version byte (position 14) is '3', not '4'
268        assert!(!validate_uuid("550e8400-e29b-31d4-a716-446655440000"));
269    }
270
271    #[test]
272    fn validate_uuid_rejects_wrong_variant() {
273        // variant byte (position 19) is 'c', not in [89abAB]
274        assert!(!validate_uuid("550e8400-e29b-41d4-c716-446655440000"));
275    }
276
277    #[test]
278    fn validate_uuid_rejects_short() {
279        assert!(!validate_uuid(""));
280        assert!(!validate_uuid("550e8400"));
281    }
282
283    #[test]
284    fn validate_uuid_rejects_missing_dashes() {
285        assert!(!validate_uuid("550e8400e29b41d4a716446655440000"));
286    }
287
288    #[test]
289    fn validate_uuid_rejects_non_hex() {
290        assert!(!validate_uuid("550g8400-e29b-41d4-a716-446655440000"));
291    }
292
293    #[test]
294    fn edge_direction_default_is_both() {
295        assert_eq!(EdgeDirection::default(), EdgeDirection::Both);
296    }
297}