1use std::collections::{BTreeMap, BTreeSet};
20
21use kimetsu_core::KimetsuResult;
22use rusqlite::Connection;
23
24use crate::consolidate::parse_tags;
25
26#[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
34pub const RELATES_TO: &str = "relates_to";
36
37pub const DEFAULT_MAX_FAN_OUT: usize = 8;
40
41const MIN_KEYWORD_LEN: usize = 5;
44
45const 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
54pub fn extract_entities(text: &str) -> Vec<String> {
63 let mut set: BTreeSet<String> = BTreeSet::new();
64
65 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 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 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
100fn 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
115pub 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 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 let mut pairs: BTreeSet<(String, String)> = BTreeSet::new();
145 for ids in by_entity.values() {
146 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; };
160 pairs.insert((a, b));
161 }
162 }
163 }
164
165 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 assert!(ents.contains(&"rust".to_string()));
211 assert!(ents.contains(&"mutex".to_string()));
212 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 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 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 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}