Skip to main content

wm_tools/expansion/
knowledge_graph.rs

1//! Knowledge graph tools — kg.extract, kg.query, kg.top.
2//!
3//! These tools build a knowledge graph on top of the association store.
4//! `kg.extract` mines entity-relationship triples from memory content and
5//! creates typed associations. `kg.query` retrieves all relationships for a
6//! given entity. `kg.top` finds the most-connected entities (hub nodes).
7
8#![forbid(unsafe_code)]
9
10use async_trait::async_trait;
11
12use serde_json::{Value, json};
13use std::collections::HashMap;
14use std::sync::Arc;
15use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
16use wm_memory::{Association, AssociationStore, LinkType, MemoryStore};
17
18use super::common::{galaxy_name, parse_galaxy};
19
20/// Simple entity extraction: capitalized words and multi-word phrases.
21///
22/// Returns a list of (entity_text, position) pairs. This is a lightweight
23/// NER that doesn't require external models — it uses capitalization
24/// heuristics and common relationship patterns.
25fn extract_entities(content: &str) -> Vec<String> {
26    let mut entities = Vec::new();
27    let words: Vec<&str> = content.split_whitespace().collect();
28
29    let mut i = 0;
30    while i < words.len() {
31        let word = words[i];
32
33        // Single capitalized word (not at sentence start, or is a proper noun)
34        if word.chars().next().is_some_and(char::is_uppercase) && word.len() > 1 {
35            // Check if it's a multi-word entity (consecutive capitalized words)
36            let mut phrase = vec![word];
37            let mut j = i + 1;
38            while j < words.len() {
39                let next = words[j];
40                if next.chars().next().is_some_and(char::is_uppercase) && next.len() > 1 {
41                    phrase.push(next);
42                    j += 1;
43                } else {
44                    break;
45                }
46            }
47            // Strip leading common words (e.g., "The White Magic" → "White Magic")
48            while phrase.len() > 1 && is_common_word(&phrase[0].to_lowercase()) {
49                phrase.remove(0);
50            }
51            let entity = phrase.join(" ");
52            // Filter out common words and empty phrases
53            let lower = entity.to_lowercase();
54            if !entity.is_empty() && !is_common_word(&lower) {
55                entities.push(entity);
56            }
57            i = j;
58        } else {
59            i += 1;
60        }
61    }
62
63    entities
64}
65
66/// Check if a word is a common English word that shouldn't be an entity.
67fn is_common_word(s: &str) -> bool {
68    matches!(
69        s,
70        "the"
71            | "a"
72            | "an"
73            | "this"
74            | "that"
75            | "these"
76            | "those"
77            | "it"
78            | "is"
79            | "was"
80            | "are"
81            | "were"
82            | "be"
83            | "been"
84            | "we"
85            | "they"
86            | "he"
87            | "she"
88            | "i"
89            | "you"
90            | "in"
91            | "on"
92            | "at"
93            | "to"
94            | "for"
95            | "of"
96            | "with"
97            | "and"
98            | "or"
99            | "but"
100            | "not"
101            | "if"
102            | "then"
103            | "when"
104            | "where"
105            | "what"
106            | "who"
107            | "how"
108            | "why"
109            | "there"
110            | "here"
111            | "so"
112            | "no"
113            | "yes"
114    )
115}
116
117/// Detect relationship type between two entities based on connecting words.
118fn detect_link_type(content: &str, entity_a: &str, entity_b: &str) -> LinkType {
119    let lower = content.to_lowercase();
120    let a_lower = entity_a.to_lowercase();
121    let b_lower = entity_b.to_lowercase();
122
123    // Find the text between the two entities
124    if let Some(pos_a) = lower.find(&a_lower) {
125        let after_a = pos_a + a_lower.len();
126        if let Some(pos_b) = lower[after_a..].find(&b_lower) {
127            let between = &lower[after_a..after_a + pos_b];
128            let between_trimmed = between.trim();
129
130            if between_trimmed.contains("because")
131                || between_trimmed.contains("causes")
132                || between_trimmed.contains("leads to")
133                || between_trimmed.contains("results in")
134            {
135                return LinkType::Causal;
136            }
137            if between_trimmed.contains("before")
138                || between_trimmed.contains("after")
139                || between_trimmed.contains("then")
140                || between_trimmed.contains("followed by")
141            {
142                return LinkType::Temporal;
143            }
144            if between_trimmed.contains("extends")
145                || between_trimmed.contains("refines")
146                || between_trimmed.contains("builds on")
147                || between_trimmed.contains("improves")
148            {
149                return LinkType::Extends;
150            }
151            if between_trimmed.contains("contradicts")
152                || between_trimmed.contains("but")
153                || between_trimmed.contains("however")
154                || between_trimmed.contains("opposes")
155            {
156                return LinkType::Contradicts;
157            }
158            if between_trimmed.contains("replaces")
159                || between_trimmed.contains("supersedes")
160                || between_trimmed.contains("instead of")
161            {
162                return LinkType::Supersedes;
163            }
164            if between_trimmed.contains("triggers")
165                || between_trimmed.contains("cascades")
166                || between_trimmed.contains("chain")
167            {
168                return LinkType::Cascade;
169            }
170        }
171    }
172    LinkType::Related
173}
174
175/// `kg.extract` — extract entities from memory content and create typed associations.
176///
177/// Scans memories in a galaxy, extracts entities (capitalized phrases),
178/// and creates typed associations between memories that share entities.
179/// The relationship type is inferred from connecting words.
180pub struct KgExtractTool {
181    store: Arc<MemoryStore>,
182    stats: ToolStats,
183    effects: EffectRow,
184}
185
186impl KgExtractTool {
187    pub fn new(store: Arc<MemoryStore>) -> Self {
188        Self {
189            store,
190            stats: ToolStats::default(),
191            effects: EffectRow {
192                writes: vec![Resource::Galaxy("associations".into())],
193                reads: vec![Resource::Galaxy("codex".into())],
194                ..Default::default()
195            },
196        }
197    }
198}
199
200#[async_trait]
201impl Tool for KgExtractTool {
202    fn name(&self) -> &str {
203        "kg.extract"
204    }
205    fn gana(&self) -> Gana {
206        Gana::Net
207    }
208    fn effects(&self) -> &EffectRow {
209        &self.effects
210    }
211    fn description(&self) -> &str {
212        "Extract entities from memory content and create typed associations (knowledge graph)"
213    }
214    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
215        let galaxy_str = args
216            .get("galaxy")
217            .and_then(|v| v.as_str())
218            .unwrap_or("codex");
219        let galaxy = parse_galaxy(galaxy_str)?;
220        let limit = args
221            .get("limit")
222            .and_then(serde_json::Value::as_u64)
223            .unwrap_or(200) as usize;
224
225        let memories = self.store.scan(galaxy, limit)?;
226        let env = self.store.env();
227        let assoc_store = AssociationStore::open(env)?;
228
229        // Build entity → memory index
230        let mut entity_index: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
231
232        for mem in &memories {
233            let entities = extract_entities(&mem.content);
234            for entity in entities {
235                entity_index
236                    .entry(entity.to_lowercase())
237                    .or_default()
238                    .push(mem.metadata.id);
239            }
240        }
241
242        // Create associations between memories sharing entities
243        let mut created = 0u32;
244        let mut skipped = 0u32;
245        for mem_ids in entity_index.values() {
246            if mem_ids.len() < 2 {
247                continue;
248            }
249            for i in 0..mem_ids.len() {
250                for j in (i + 1)..mem_ids.len() {
251                    let src = mem_ids[i];
252                    let tgt = mem_ids[j];
253
254                    // Skip if association already exists
255                    if assoc_store.get(env, src, tgt).unwrap_or(None).is_some() {
256                        skipped += 1;
257                        continue;
258                    }
259
260                    // Detect link type from content
261                    let src_mem = memories.iter().find(|m| m.metadata.id == src);
262                    let tgt_mem = memories.iter().find(|m| m.metadata.id == tgt);
263                    let link_type = if let (Some(s), Some(t)) = (src_mem, tgt_mem) {
264                        // Use the source content to detect relationship
265                        detect_link_type(&s.content, &s.content, &t.content)
266                    } else {
267                        LinkType::Related
268                    };
269
270                    let weight = 0.5f32.mul_add((mem_ids.len() - 2).min(5) as f32, 0.5);
271                    let assoc = Association::new(src, tgt, link_type, weight.min(1.0));
272                    let _ = assoc_store.put(env, &assoc);
273                    created += 1;
274                }
275            }
276        }
277
278        Ok(json!({
279            "status": "success",
280            "galaxy": galaxy_name(galaxy),
281            "scanned": memories.len(),
282            "entities_found": entity_index.len(),
283            "associations_created": created,
284            "associations_skipped_existing": skipped,
285        }))
286    }
287    fn stats(&self) -> &ToolStats {
288        &self.stats
289    }
290}
291
292/// `kg.query` — query the knowledge graph for a given entity.
293///
294/// Finds all memories containing the entity, then retrieves all associations
295/// for those memories. Returns the subgraph around the entity.
296pub struct KgQueryTool {
297    store: Arc<MemoryStore>,
298    stats: ToolStats,
299    effects: EffectRow,
300}
301
302impl KgQueryTool {
303    pub fn new(store: Arc<MemoryStore>) -> Self {
304        Self {
305            store,
306            stats: ToolStats::default(),
307            effects: EffectRow::read_only(vec![
308                Resource::Galaxy("codex".into()),
309                Resource::Galaxy("associations".into()),
310            ]),
311        }
312    }
313}
314
315#[async_trait]
316impl Tool for KgQueryTool {
317    fn name(&self) -> &str {
318        "kg.query"
319    }
320    fn gana(&self) -> Gana {
321        Gana::Net
322    }
323    fn effects(&self) -> &EffectRow {
324        &self.effects
325    }
326    fn description(&self) -> &str {
327        "Query the knowledge graph for an entity (find memories and associations)"
328    }
329    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
330        let entity = args
331            .get("entity")
332            .and_then(|v| v.as_str())
333            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'entity' parameter".into()))?;
334        let galaxy_str = args
335            .get("galaxy")
336            .and_then(|v| v.as_str())
337            .unwrap_or("codex");
338        let galaxy = parse_galaxy(galaxy_str)?;
339        let limit = args
340            .get("limit")
341            .and_then(serde_json::Value::as_u64)
342            .unwrap_or(50) as usize;
343
344        let memories = self.store.scan(galaxy, limit)?;
345        let entity_lower = entity.to_lowercase();
346
347        // Find memories containing the entity
348        let matching: Vec<_> = memories
349            .iter()
350            .filter(|m| m.content.to_lowercase().contains(&entity_lower))
351            .collect();
352
353        let env = self.store.env();
354        let assoc_store = AssociationStore::open(env)?;
355
356        // Collect all associations for matching memories
357        let mut edges = Vec::new();
358        let mut connected_ids: std::collections::HashSet<uuid::Uuid> =
359            std::collections::HashSet::new();
360
361        for mem in &matching {
362            let from = assoc_store
363                .find_from(env, mem.metadata.id)
364                .unwrap_or_default();
365            let to = assoc_store
366                .find_to(env, mem.metadata.id)
367                .unwrap_or_default();
368
369            for a in &from {
370                edges.push(json!({
371                    "source": a.source,
372                    "target": a.target,
373                    "link_type": a.link_type.as_str(),
374                    "weight": a.weight,
375                }));
376                connected_ids.insert(a.target);
377            }
378            for a in &to {
379                edges.push(json!({
380                    "source": a.source,
381                    "target": a.target,
382                    "link_type": a.link_type.as_str(),
383                    "weight": a.weight,
384                }));
385                connected_ids.insert(a.source);
386            }
387        }
388
389        let node_mems: Vec<Value> = matching
390            .iter()
391            .map(|m| {
392                json!({
393                    "id": m.metadata.id,
394                    "content_preview": m.content.chars().take(200).collect::<String>(),
395                    "tags": m.metadata.tags,
396                    "galaxy": galaxy_name(galaxy),
397                })
398            })
399            .collect();
400
401        Ok(json!({
402            "status": "success",
403            "entity": entity,
404            "galaxy": galaxy_name(galaxy),
405            "matching_memories": matching.len(),
406            "nodes": node_mems,
407            "edges": edges,
408            "edge_count": edges.len(),
409            "connected_entities": connected_ids.len(),
410        }))
411    }
412    fn stats(&self) -> &ToolStats {
413        &self.stats
414    }
415}
416
417/// `kg.top` — find top entities by connection count (hub/god nodes).
418///
419/// Scans memories, extracts entities, and ranks them by the number of
420/// memories they appear in. Returns the top N entities with their
421/// connection counts and sample memories.
422pub struct KgTopTool {
423    store: Arc<MemoryStore>,
424    stats: ToolStats,
425    effects: EffectRow,
426}
427
428impl KgTopTool {
429    pub fn new(store: Arc<MemoryStore>) -> Self {
430        Self {
431            store,
432            stats: ToolStats::default(),
433            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
434        }
435    }
436}
437
438#[async_trait]
439impl Tool for KgTopTool {
440    fn name(&self) -> &str {
441        "kg.top"
442    }
443    fn gana(&self) -> Gana {
444        Gana::HairyHead
445    }
446    fn effects(&self) -> &EffectRow {
447        &self.effects
448    }
449    fn description(&self) -> &str {
450        "Find top entities by connection count (hub/god nodes in the knowledge graph)"
451    }
452    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
453        let galaxy_str = args
454            .get("galaxy")
455            .and_then(|v| v.as_str())
456            .unwrap_or("codex");
457        let galaxy = parse_galaxy(galaxy_str)?;
458        let limit = args
459            .get("limit")
460            .and_then(serde_json::Value::as_u64)
461            .unwrap_or(500) as usize;
462        let top_n = args
463            .get("top_n")
464            .and_then(serde_json::Value::as_u64)
465            .unwrap_or(10) as usize;
466
467        let memories = self.store.scan(galaxy, limit)?;
468
469        // Build entity frequency map
470        let mut entity_freq: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
471        for mem in &memories {
472            let entities = extract_entities(&mem.content);
473            for entity in entities {
474                let entry = entity_freq.entry(entity.to_lowercase()).or_default();
475                if !entry.contains(&mem.metadata.id) {
476                    entry.push(mem.metadata.id);
477                }
478            }
479        }
480
481        // Sort by connection count (descending)
482        let mut ranked: Vec<(String, Vec<uuid::Uuid>)> = entity_freq.into_iter().collect();
483        ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1.len()));
484
485        let top: Vec<Value> = ranked
486            .iter()
487            .take(top_n)
488            .map(|(entity, ids)| {
489                json!({
490                    "entity": entity,
491                    "memory_count": ids.len(),
492                    "sample_memory_ids": ids.iter().take(5).map(std::string::ToString::to_string).collect::<Vec<_>>(),
493                })
494            })
495            .collect();
496
497        Ok(json!({
498            "status": "success",
499            "galaxy": galaxy_name(galaxy),
500            "scanned": memories.len(),
501            "total_entities": ranked.len(),
502            "top_entities": top,
503        }))
504    }
505    fn stats(&self) -> &ToolStats {
506        &self.stats
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use tempfile::tempdir;
514
515    fn open_store() -> (tempfile::TempDir, MemoryStore) {
516        let tmp = tempdir().unwrap();
517        let store = MemoryStore::open_default(tmp.path()).unwrap();
518        (tmp, store)
519    }
520
521    #[tokio::test]
522    async fn extract_entities_finds_capitalized_words() {
523        let entities = extract_entities("Rust is a language. Python is also great.");
524        assert!(entities.contains(&"Rust".to_string()));
525        assert!(entities.contains(&"Python".to_string()));
526    }
527
528    #[tokio::test]
529    async fn extract_entities_finds_multi_word_phrases() {
530        let entities = extract_entities("The White Magic project uses LMDB storage.");
531        assert!(entities.contains(&"White Magic".to_string()));
532        assert!(entities.contains(&"LMDB".to_string()));
533    }
534
535    #[tokio::test]
536    async fn extract_entities_ignores_common_words() {
537        let entities = extract_entities("The quick brown fox jumps over the lazy dog.");
538        // "The" at sentence start should be filtered
539        assert!(!entities.iter().any(|e| e == "The"));
540    }
541
542    #[tokio::test]
543    async fn detect_link_type_causal() {
544        let lt = detect_link_type(
545            "Rust causes fast performance because of zero-cost abstractions",
546            "Rust",
547            "zero-cost abstractions",
548        );
549        assert_eq!(lt, LinkType::Causal);
550    }
551
552    #[tokio::test]
553    async fn detect_link_type_temporal() {
554        let lt = detect_link_type(
555            "First we tried Python, then we switched to Rust",
556            "Python",
557            "Rust",
558        );
559        assert_eq!(lt, LinkType::Temporal);
560    }
561
562    #[tokio::test]
563    async fn detect_link_type_related_default() {
564        let lt = detect_link_type("Rust and Python are languages", "Rust", "Python");
565        assert_eq!(lt, LinkType::Related);
566    }
567
568    #[tokio::test]
569    async fn kg_extract_creates_associations() {
570        let (_tmp, store) = open_store();
571        let store = Arc::new(store);
572
573        let mem1 = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Rust is a fast language".into());
574        let mem2 = wm_memory::Memory::new(
575            wm_core::Galaxy::Codex,
576            "Rust is also a great language".into(),
577        );
578        store.put(wm_core::Galaxy::Codex, &mem1).unwrap();
579        store.put(wm_core::Galaxy::Codex, &mem2).unwrap();
580
581        let tool = KgExtractTool::new(store.clone());
582        let result = tool
583            .call(&mut Context::default(), json!({"galaxy": "codex"}))
584            .await
585            .unwrap();
586        let obj = result.as_object().unwrap();
587        assert_eq!(obj["status"], "success");
588        assert_eq!(obj["scanned"], 2);
589        // "Rust" appears in both memories, so at least 1 entity
590        assert!(obj["entities_found"].as_u64().unwrap() >= 1);
591
592        // Verify associations were created (Rust links the two memories)
593        let env = store.env();
594        let assoc_store = AssociationStore::open(env).unwrap();
595        let count = assoc_store.count(env).unwrap();
596        assert!(count > 0, "should have created at least one association");
597    }
598
599    #[tokio::test]
600    async fn kg_query_finds_entity() {
601        let (_tmp, store) = open_store();
602
603        let mem = wm_memory::Memory::new(
604            wm_core::Galaxy::Codex,
605            "Rust is a systems programming language".into(),
606        );
607        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
608
609        let tool = KgQueryTool::new(Arc::new(store));
610        let result = tool
611            .call(
612                &mut Context::default(),
613                json!({"entity": "Rust", "galaxy": "codex"}),
614            )
615            .await
616            .unwrap();
617        let obj = result.as_object().unwrap();
618        assert_eq!(obj["status"], "success");
619        assert_eq!(obj["matching_memories"], 1);
620        assert_eq!(obj["entity"], "Rust");
621    }
622
623    #[tokio::test]
624    async fn kg_query_missing_entity_param_errors() {
625        let (_tmp, store) = open_store();
626        let tool = KgQueryTool::new(Arc::new(store));
627        let result = tool.call(&mut Context::default(), json!({})).await;
628        assert!(result.is_err());
629    }
630
631    #[tokio::test]
632    async fn kg_top_ranks_entities() {
633        let (_tmp, store) = open_store();
634
635        // Create memories with a shared entity
636        for i in 0..5 {
637            let content = format!("Rust is mentioned in memory number {i}");
638            let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, content);
639            store.put(wm_core::Galaxy::Codex, &mem).unwrap();
640        }
641        // One memory without Rust
642        let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Python is great".into());
643        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
644
645        let tool = KgTopTool::new(Arc::new(store));
646        let result = tool
647            .call(
648                &mut Context::default(),
649                json!({"galaxy": "codex", "top_n": 5}),
650            )
651            .await
652            .unwrap();
653        let obj = result.as_object().unwrap();
654        assert_eq!(obj["status"], "success");
655        assert_eq!(obj["scanned"], 6);
656        let top = obj["top_entities"].as_array().unwrap();
657        assert!(!top.is_empty());
658        // Rust should be the top entity (appears in 5 memories)
659        let top_entity = top[0]["entity"].as_str().unwrap();
660        assert_eq!(top_entity, "rust");
661        assert_eq!(top[0]["memory_count"], 5);
662    }
663
664    #[tokio::test]
665    async fn kg_tool_names_are_correct() {
666        let store = Arc::new(open_store().1);
667        assert_eq!(KgExtractTool::new(store.clone()).name(), "kg.extract");
668        assert_eq!(KgQueryTool::new(store.clone()).name(), "kg.query");
669        assert_eq!(KgTopTool::new(store).name(), "kg.top");
670    }
671
672    #[tokio::test]
673    async fn kg_tool_ganas_are_correct() {
674        let store = Arc::new(open_store().1);
675        assert_eq!(KgExtractTool::new(store.clone()).gana(), Gana::Net);
676        assert_eq!(KgQueryTool::new(store.clone()).gana(), Gana::Net);
677        assert_eq!(KgTopTool::new(store).gana(), Gana::HairyHead);
678    }
679}