Skip to main content

kimetsu_brain/
graph.rs

1//! #2 knowledge graph: rule-based relation-edge extraction.
2//!
3//! Today the only edges in `memory_edges` are `"supersedes"` (written by
4//! consolidation), and those point at superseded memories that retrieval already
5//! excludes — so the graph-lite / petgraph backends behave like flat retrieval.
6//! This module derives MEANINGFUL `"relates_to"` edges between *active* memories
7//! that share a salient entity, so a query that hits memory A can reach a linked
8//! memory B it does not directly match (multi-hop retrieval).
9//!
10//! The rule layer is fully deterministic and model-free: it parses inline
11//! `[tags: ...]` markers (via [`crate::consolidate::parse_tags`]) plus a small
12//! salient-term pass, indexes memories by entity, and links every pair that
13//! shares at least one entity. The optional LLM enrichment layer (`--enrich`)
14//! lives in the CLI, where the cheap-model provider is resolved.
15//!
16//! Edges are persisted as `memory.edge` events via
17//! [`crate::projector::add_memory_edges`], so they are rebuild-safe.
18
19use std::collections::{BTreeMap, BTreeSet};
20
21use kimetsu_core::KimetsuResult;
22use rusqlite::Connection;
23
24use crate::consolidate::parse_tags;
25
26/// A proposed relation edge between two active memories.
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
28pub struct EdgeProposal {
29    pub src_id: String,
30    pub dst_id: String,
31    pub edge_type: String,
32}
33
34/// The rule-layer edge type.
35pub const RELATES_TO: &str = "relates_to";
36
37/// Default cap on how many edges any single memory may originate, to stop a
38/// common entity (shared by many memories) from producing a quadratic hairball.
39pub const DEFAULT_MAX_FAN_OUT: usize = 8;
40
41/// Minimum length for a salient bare keyword to count as an entity. Short tokens
42/// ("the", "a", "is") carry no linking signal.
43const MIN_KEYWORD_LEN: usize = 5;
44
45/// A small stop-list of common-but-uninformative long-ish words that would
46/// otherwise link unrelated memories. Kept deliberately tiny and lowercase.
47const STOPWORDS: &[&str] = &[
48    "about", "above", "after", "again", "against", "always", "because", "before", "being", "below",
49    "between", "could", "default", "during", "every", "first", "found", "their", "there", "these",
50    "thing", "things", "those", "through", "under", "until", "using", "value", "where", "which",
51    "while", "would", "should", "while",
52];
53
54/// Extract salient entities/keywords from one memory's text. The result is
55/// lowercased and de-duplicated. Two sources:
56///   1. inline `[tags: ...]` markers (high-signal, author/distiller supplied),
57///   2. salient bare tokens — alphanumeric words of length >= `MIN_KEYWORD_LEN`
58///      that are not stopwords (lowercased). Capitalized proper nouns are kept
59///      regardless of stopword status (they are distinctive).
60///
61/// Deterministic and pure — no allocation order dependence (returns sorted).
62pub fn extract_entities(text: &str) -> Vec<String> {
63    let mut set: BTreeSet<String> = BTreeSet::new();
64
65    // 1. Inline tags (already lowercased + deduped by parse_tags). Tags in this
66    //    codebase are space-separated inside the block (`[tags: rust mutex ann]`),
67    //    while parse_tags only splits on commas — so split each returned tag on
68    //    whitespace to recover individual high-signal tag words.
69    for t in parse_tags(text) {
70        for word in t.split_whitespace() {
71            let w = word.trim();
72            if w.len() >= 3 {
73                set.insert(w.to_string());
74            }
75        }
76    }
77
78    // 2. Salient bare tokens. Split on non-alphanumeric; keep informative ones.
79    for raw in text.split(|c: char| !c.is_alphanumeric()) {
80        if raw.is_empty() {
81            continue;
82        }
83        let is_proper = raw.chars().next().is_some_and(|c| c.is_uppercase())
84            && raw.chars().skip(1).any(|c| c.is_lowercase());
85        let lower = raw.to_ascii_lowercase();
86        // Distinctive proper noun (kept even if short), OR an informative long
87        // token that is not a stopword.
88        let proper_kept = is_proper && lower.len() >= 3;
89        let informative = lower.len() >= MIN_KEYWORD_LEN
90            && !STOPWORDS.contains(&lower.as_str())
91            && lower.chars().any(|c| c.is_alphabetic());
92        if proper_kept || informative {
93            set.insert(lower);
94        }
95    }
96
97    set.into_iter().collect()
98}
99
100/// Load every active (not invalidated, not superseded) memory as `(id, text)`,
101/// ordered by id for deterministic edge generation.
102fn load_active_memories(conn: &Connection) -> KimetsuResult<Vec<(String, String)>> {
103    let mut stmt = conn.prepare(
104        "SELECT memory_id, text
105         FROM memories
106         WHERE invalidated_at IS NULL AND superseded_by IS NULL
107         ORDER BY memory_id",
108    )?;
109    let rows = stmt
110        .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
111        .collect::<Result<Vec<_>, _>>()?;
112    Ok(rows)
113}
114
115/// Build rule-based `relates_to` edge proposals over all active memories: any two
116/// memories sharing >= 1 extracted entity are linked. Edges are undirected in
117/// meaning but stored once as `src < dst` (graph-lite traverses both directions),
118/// so each related pair yields exactly one proposal. `max_fan_out` caps the
119/// number of edges per source memory (0 = use [`DEFAULT_MAX_FAN_OUT`]).
120///
121/// Returns proposals sorted and de-duplicated; deterministic for a given brain
122/// state. Pure read — does not write anything (the caller persists via
123/// [`crate::projector::add_memory_edges`]).
124pub fn build_relates_to_edges(
125    conn: &Connection,
126    max_fan_out: usize,
127) -> KimetsuResult<Vec<EdgeProposal>> {
128    let cap = if max_fan_out == 0 {
129        DEFAULT_MAX_FAN_OUT
130    } else {
131        max_fan_out
132    };
133    let memories = load_active_memories(conn)?;
134
135    // entity -> sorted list of memory ids that mention it.
136    let mut by_entity: BTreeMap<String, Vec<String>> = BTreeMap::new();
137    for (id, text) in &memories {
138        for entity in extract_entities(text) {
139            by_entity.entry(entity).or_default().push(id.clone());
140        }
141    }
142
143    // Collect undirected pairs (a < b) that co-mention any entity.
144    let mut pairs: BTreeSet<(String, String)> = BTreeSet::new();
145    for ids in by_entity.values() {
146        // Skip ubiquitous entities: if a single entity is shared by a large
147        // fraction of memories it is noise, not signal. Cap the group size.
148        if ids.len() < 2 || ids.len() > cap.max(2) * 4 {
149            continue;
150        }
151        for i in 0..ids.len() {
152            for j in (i + 1)..ids.len() {
153                let (a, b) = if ids[i] < ids[j] {
154                    (ids[i].clone(), ids[j].clone())
155                } else if ids[i] > ids[j] {
156                    (ids[j].clone(), ids[i].clone())
157                } else {
158                    continue; // same id under one entity (shouldn't happen)
159                };
160                pairs.insert((a, b));
161            }
162        }
163    }
164
165    // Enforce per-source fan-out cap deterministically (pairs are already sorted).
166    let mut fan_out: BTreeMap<String, usize> = BTreeMap::new();
167    let mut proposals: Vec<EdgeProposal> = Vec::new();
168    for (a, b) in pairs {
169        let ca = fan_out.entry(a.clone()).or_insert(0);
170        if *ca >= cap {
171            continue;
172        }
173        *ca += 1;
174        proposals.push(EdgeProposal {
175            src_id: a,
176            dst_id: b,
177            edge_type: RELATES_TO.to_string(),
178        });
179    }
180    Ok(proposals)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::projector::add_memory_edges;
187    use crate::schema;
188    use rusqlite::params;
189
190    fn make_conn() -> Connection {
191        let conn = Connection::open_in_memory().expect("open_in_memory");
192        schema::initialize(&conn).expect("schema::initialize");
193        conn
194    }
195
196    fn insert_active_memory(conn: &Connection, id: &str, text: &str) {
197        conn.execute(
198            "INSERT INTO memories
199             (memory_id, scope, kind, text, normalized_text, confidence, provenance_snapshot_json, created_at)
200             VALUES (?1, 'global_user', 'fact', ?2, ?2, 0.85, '{}', '2024-01-01T00:00:00Z')",
201            params![id, text],
202        )
203        .expect("insert memory");
204    }
205
206    #[test]
207    fn extract_entities_picks_tags_and_salient_terms() {
208        let ents = extract_entities("[tags: rust mutex] Holding a Mutex across an await deadlocks");
209        // Inline tags present.
210        assert!(ents.contains(&"rust".to_string()));
211        assert!(ents.contains(&"mutex".to_string()));
212        // Salient long token kept; short stopword-ish dropped.
213        assert!(ents.contains(&"deadlocks".to_string()));
214        assert!(!ents.contains(&"a".to_string()));
215        assert!(!ents.contains(&"an".to_string()));
216    }
217
218    #[test]
219    fn extract_entities_is_sorted_and_deduped() {
220        let ents = extract_entities("Docker docker DOCKER mount mount");
221        let mut sorted = ents.clone();
222        sorted.sort();
223        assert_eq!(ents, sorted, "entities must be returned sorted");
224        let set: BTreeSet<&String> = ents.iter().collect();
225        assert_eq!(set.len(), ents.len(), "no duplicates");
226    }
227
228    #[test]
229    fn build_edges_links_shared_entity_and_skips_unrelated() {
230        let conn = make_conn();
231        // a & b share "deadlock"; c is unrelated.
232        insert_active_memory(
233            &conn,
234            "a",
235            "[tags: deadlock] holding a mutex guard deadlock risk",
236        );
237        insert_active_memory(
238            &conn,
239            "b",
240            "the async runtime can deadlock under contention",
241        );
242        insert_active_memory(
243            &conn,
244            "c",
245            "the website landing page uses a teal gradient hero",
246        );
247
248        let edges = build_relates_to_edges(&conn, 0).expect("build");
249        // Exactly one undirected pair (a,b), stored as src<dst.
250        assert_eq!(edges.len(), 1, "got {edges:?}");
251        assert_eq!(edges[0].src_id, "a");
252        assert_eq!(edges[0].dst_id, "b");
253        assert_eq!(edges[0].edge_type, RELATES_TO);
254    }
255
256    #[test]
257    fn build_edges_persist_roundtrip() {
258        let conn = make_conn();
259        insert_active_memory(&conn, "a", "windows docker named pipe mount rule");
260        insert_active_memory(&conn, "b", "docker mount breaks under a tcp host");
261
262        let edges = build_relates_to_edges(&conn, 0).expect("build");
263        assert!(!edges.is_empty());
264        let tuples: Vec<(String, String, String)> = edges
265            .iter()
266            .map(|e| (e.src_id.clone(), e.dst_id.clone(), e.edge_type.clone()))
267            .collect();
268        let written = add_memory_edges(&conn, &tuples).expect("persist");
269        assert_eq!(written, edges.len());
270
271        let n: i64 = conn
272            .query_row(
273                "SELECT COUNT(*) FROM memory_edges WHERE edge_type='relates_to'",
274                [],
275                |r| r.get(0),
276            )
277            .unwrap();
278        assert_eq!(n as usize, edges.len());
279    }
280
281    #[test]
282    fn build_edges_excludes_superseded() {
283        let conn = make_conn();
284        insert_active_memory(&conn, "a", "shared topic alpha beta gamma");
285        insert_active_memory(&conn, "b", "shared topic alpha beta gamma too");
286        // Supersede b: it must drop out of the active set, leaving no pair.
287        conn.execute(
288            "UPDATE memories SET superseded_by = 'a' WHERE memory_id = 'b'",
289            [],
290        )
291        .unwrap();
292        let edges = build_relates_to_edges(&conn, 0).expect("build");
293        assert!(edges.is_empty(), "superseded memory must not be linked");
294    }
295}