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, schema};
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    /// `entity` is read unconditionally by `call` (2026-09-15 contract
327    /// backlog: the schema omitted it, so discovery could not know the
328    /// requirement and callers had to reverse-engineer it from an error).
329    fn input_schema(&self) -> Value {
330        schema(
331            &json!({
332                "entity": super::common::str_prop("Entity name to query (required)"),
333                "galaxy": super::common::str_prop("Galaxy filter (default: codex)"),
334                "limit": super::common::int_prop("Maximum results (default 50)"),
335            }),
336            &["entity"],
337        )
338    }
339    fn description(&self) -> &str {
340        "Query the knowledge graph for an entity (find memories and associations)"
341    }
342    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
343        let entity = args
344            .get("entity")
345            .and_then(|v| v.as_str())
346            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'entity' parameter".into()))?;
347        let galaxy_str = args
348            .get("galaxy")
349            .and_then(|v| v.as_str())
350            .unwrap_or("codex");
351        let galaxy = parse_galaxy(galaxy_str)?;
352        let limit = args
353            .get("limit")
354            .and_then(serde_json::Value::as_u64)
355            .unwrap_or(50) as usize;
356
357        let memories = self.store.scan(galaxy, limit)?;
358        let entity_lower = entity.to_lowercase();
359
360        // Find memories containing the entity
361        let matching: Vec<_> = memories
362            .iter()
363            .filter(|m| m.content.to_lowercase().contains(&entity_lower))
364            .collect();
365
366        let env = self.store.env();
367        let assoc_store = AssociationStore::open(env)?;
368
369        // Collect all associations for matching memories
370        let mut edges = Vec::new();
371        let mut connected_ids: std::collections::HashSet<uuid::Uuid> =
372            std::collections::HashSet::new();
373
374        for mem in &matching {
375            let from = assoc_store
376                .find_from(env, mem.metadata.id)
377                .unwrap_or_default();
378            let to = assoc_store
379                .find_to(env, mem.metadata.id)
380                .unwrap_or_default();
381
382            for a in &from {
383                edges.push(json!({
384                    "source": a.source,
385                    "target": a.target,
386                    "link_type": a.link_type.as_str(),
387                    "weight": a.weight,
388                }));
389                connected_ids.insert(a.target);
390            }
391            for a in &to {
392                edges.push(json!({
393                    "source": a.source,
394                    "target": a.target,
395                    "link_type": a.link_type.as_str(),
396                    "weight": a.weight,
397                }));
398                connected_ids.insert(a.source);
399            }
400        }
401
402        let node_mems: Vec<Value> = matching
403            .iter()
404            .map(|m| {
405                json!({
406                    "id": m.metadata.id,
407                    "content_preview": m.content.chars().take(200).collect::<String>(),
408                    "tags": m.metadata.tags,
409                    "galaxy": galaxy_name(galaxy),
410                })
411            })
412            .collect();
413
414        Ok(json!({
415            "status": "success",
416            "entity": entity,
417            "galaxy": galaxy_name(galaxy),
418            "matching_memories": matching.len(),
419            "nodes": node_mems,
420            "edges": edges,
421            "edge_count": edges.len(),
422            "connected_entities": connected_ids.len(),
423        }))
424    }
425    fn stats(&self) -> &ToolStats {
426        &self.stats
427    }
428}
429
430/// `kg.top` — find top entities by connection count (hub/god nodes).
431///
432/// Scans memories, extracts entities, and ranks them by the number of
433/// memories they appear in. Returns the top N entities with their
434/// connection counts and sample memories.
435pub struct KgTopTool {
436    store: Arc<MemoryStore>,
437    stats: ToolStats,
438    effects: EffectRow,
439}
440
441impl KgTopTool {
442    pub fn new(store: Arc<MemoryStore>) -> Self {
443        Self {
444            store,
445            stats: ToolStats::default(),
446            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
447        }
448    }
449}
450
451#[async_trait]
452impl Tool for KgTopTool {
453    fn name(&self) -> &str {
454        "kg.top"
455    }
456    fn gana(&self) -> Gana {
457        Gana::HairyHead
458    }
459    fn effects(&self) -> &EffectRow {
460        &self.effects
461    }
462    fn description(&self) -> &str {
463        "Find top entities by connection count (hub/god nodes in the knowledge graph)"
464    }
465    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
466        let galaxy_str = args
467            .get("galaxy")
468            .and_then(|v| v.as_str())
469            .unwrap_or("codex");
470        let galaxy = parse_galaxy(galaxy_str)?;
471        let limit = args
472            .get("limit")
473            .and_then(serde_json::Value::as_u64)
474            .unwrap_or(500) as usize;
475        let top_n = args
476            .get("top_n")
477            .and_then(serde_json::Value::as_u64)
478            .unwrap_or(10) as usize;
479
480        let memories = self.store.scan(galaxy, limit)?;
481
482        // Build entity frequency map
483        let mut entity_freq: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
484        for mem in &memories {
485            let entities = extract_entities(&mem.content);
486            for entity in entities {
487                let entry = entity_freq.entry(entity.to_lowercase()).or_default();
488                if !entry.contains(&mem.metadata.id) {
489                    entry.push(mem.metadata.id);
490                }
491            }
492        }
493
494        // Sort by connection count (descending)
495        let mut ranked: Vec<(String, Vec<uuid::Uuid>)> = entity_freq.into_iter().collect();
496        ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1.len()));
497
498        let top: Vec<Value> = ranked
499            .iter()
500            .take(top_n)
501            .map(|(entity, ids)| {
502                json!({
503                    "entity": entity,
504                    "memory_count": ids.len(),
505                    "sample_memory_ids": ids.iter().take(5).map(std::string::ToString::to_string).collect::<Vec<_>>(),
506                })
507            })
508            .collect();
509
510        Ok(json!({
511            "status": "success",
512            "galaxy": galaxy_name(galaxy),
513            "scanned": memories.len(),
514            "total_entities": ranked.len(),
515            "top_entities": top,
516        }))
517    }
518    fn stats(&self) -> &ToolStats {
519        &self.stats
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use tempfile::tempdir;
527
528    fn open_store() -> (tempfile::TempDir, MemoryStore) {
529        let tmp = tempdir().unwrap();
530        let store = MemoryStore::open_default(tmp.path()).unwrap();
531        (tmp, store)
532    }
533
534    #[tokio::test]
535    async fn extract_entities_finds_capitalized_words() {
536        let entities = extract_entities("Rust is a language. Python is also great.");
537        assert!(entities.contains(&"Rust".to_string()));
538        assert!(entities.contains(&"Python".to_string()));
539    }
540
541    #[tokio::test]
542    async fn extract_entities_finds_multi_word_phrases() {
543        let entities = extract_entities("The White Magic project uses LMDB storage.");
544        assert!(entities.contains(&"White Magic".to_string()));
545        assert!(entities.contains(&"LMDB".to_string()));
546    }
547
548    #[tokio::test]
549    async fn extract_entities_ignores_common_words() {
550        let entities = extract_entities("The quick brown fox jumps over the lazy dog.");
551        // "The" at sentence start should be filtered
552        assert!(!entities.iter().any(|e| e == "The"));
553    }
554
555    #[tokio::test]
556    async fn detect_link_type_causal() {
557        let lt = detect_link_type(
558            "Rust causes fast performance because of zero-cost abstractions",
559            "Rust",
560            "zero-cost abstractions",
561        );
562        assert_eq!(lt, LinkType::Causal);
563    }
564
565    #[tokio::test]
566    async fn detect_link_type_temporal() {
567        let lt = detect_link_type(
568            "First we tried Python, then we switched to Rust",
569            "Python",
570            "Rust",
571        );
572        assert_eq!(lt, LinkType::Temporal);
573    }
574
575    #[tokio::test]
576    async fn detect_link_type_related_default() {
577        let lt = detect_link_type("Rust and Python are languages", "Rust", "Python");
578        assert_eq!(lt, LinkType::Related);
579    }
580
581    #[tokio::test]
582    async fn kg_extract_creates_associations() {
583        let (_tmp, store) = open_store();
584        let store = Arc::new(store);
585
586        let mem1 = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Rust is a fast language".into());
587        let mem2 = wm_memory::Memory::new(
588            wm_core::Galaxy::Codex,
589            "Rust is also a great language".into(),
590        );
591        store.put(wm_core::Galaxy::Codex, &mem1).unwrap();
592        store.put(wm_core::Galaxy::Codex, &mem2).unwrap();
593
594        let tool = KgExtractTool::new(store.clone());
595        let result = tool
596            .call(&mut Context::default(), json!({"galaxy": "codex"}))
597            .await
598            .unwrap();
599        let obj = result.as_object().unwrap();
600        assert_eq!(obj["status"], "success");
601        assert_eq!(obj["scanned"], 2);
602        // "Rust" appears in both memories, so at least 1 entity
603        assert!(obj["entities_found"].as_u64().unwrap() >= 1);
604
605        // Verify associations were created (Rust links the two memories)
606        let env = store.env();
607        let assoc_store = AssociationStore::open(env).unwrap();
608        let count = assoc_store.count(env).unwrap();
609        assert!(count > 0, "should have created at least one association");
610    }
611
612    #[tokio::test]
613    async fn kg_query_finds_entity() {
614        let (_tmp, store) = open_store();
615
616        let mem = wm_memory::Memory::new(
617            wm_core::Galaxy::Codex,
618            "Rust is a systems programming language".into(),
619        );
620        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
621
622        let tool = KgQueryTool::new(Arc::new(store));
623        let result = tool
624            .call(
625                &mut Context::default(),
626                json!({"entity": "Rust", "galaxy": "codex"}),
627            )
628            .await
629            .unwrap();
630        let obj = result.as_object().unwrap();
631        assert_eq!(obj["status"], "success");
632        assert_eq!(obj["matching_memories"], 1);
633        assert_eq!(obj["entity"], "Rust");
634    }
635
636    #[tokio::test]
637    async fn kg_query_missing_entity_param_errors() {
638        let (_tmp, store) = open_store();
639        let tool = KgQueryTool::new(Arc::new(store));
640        let result = tool.call(&mut Context::default(), json!({})).await;
641        assert!(result.is_err());
642    }
643
644    #[tokio::test]
645    async fn kg_top_ranks_entities() {
646        let (_tmp, store) = open_store();
647
648        // Create memories with a shared entity
649        for i in 0..5 {
650            let content = format!("Rust is mentioned in memory number {i}");
651            let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, content);
652            store.put(wm_core::Galaxy::Codex, &mem).unwrap();
653        }
654        // One memory without Rust
655        let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Python is great".into());
656        store.put(wm_core::Galaxy::Codex, &mem).unwrap();
657
658        let tool = KgTopTool::new(Arc::new(store));
659        let result = tool
660            .call(
661                &mut Context::default(),
662                json!({"galaxy": "codex", "top_n": 5}),
663            )
664            .await
665            .unwrap();
666        let obj = result.as_object().unwrap();
667        assert_eq!(obj["status"], "success");
668        assert_eq!(obj["scanned"], 6);
669        let top = obj["top_entities"].as_array().unwrap();
670        assert!(!top.is_empty());
671        // Rust should be the top entity (appears in 5 memories)
672        let top_entity = top[0]["entity"].as_str().unwrap();
673        assert_eq!(top_entity, "rust");
674        assert_eq!(top[0]["memory_count"], 5);
675    }
676
677    #[tokio::test]
678    async fn kg_tool_names_are_correct() {
679        let store = Arc::new(open_store().1);
680        assert_eq!(KgExtractTool::new(store.clone()).name(), "kg.extract");
681        assert_eq!(KgQueryTool::new(store.clone()).name(), "kg.query");
682        assert_eq!(KgTopTool::new(store).name(), "kg.top");
683    }
684
685    #[tokio::test]
686    async fn kg_tool_ganas_are_correct() {
687        let store = Arc::new(open_store().1);
688        assert_eq!(KgExtractTool::new(store.clone()).gana(), Gana::Net);
689        assert_eq!(KgQueryTool::new(store.clone()).gana(), Gana::Net);
690        assert_eq!(KgTopTool::new(store).gana(), Gana::HairyHead);
691    }
692}