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