Skip to main content

wm_tools/expansion/
graph.rs

1//! Living graph tools — graph.walk, graph.community, graph.propagate.
2//!
3//! These tools operate on the association graph, providing traversal,
4//! community detection, and activation propagation capabilities.
5
6#![forbid(unsafe_code)]
7
8use async_trait::async_trait;
9
10use serde_json::{Value, json};
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::sync::Arc;
13use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
14use wm_memory::{AssociationStore, MemoryStore};
15
16/// `graph.walk` — BFS traversal from a starting memory through associations.
17///
18/// Starting from a memory UUID, walks the association graph breadth-first
19/// up to a configurable depth. Returns the visited nodes and edges.
20pub struct GraphWalkTool {
21    store: Arc<MemoryStore>,
22    stats: ToolStats,
23    effects: EffectRow,
24}
25
26impl GraphWalkTool {
27    pub fn new(store: Arc<MemoryStore>) -> Self {
28        Self {
29            store,
30            stats: ToolStats::default(),
31            effects: EffectRow::read_only(vec![
32                Resource::Galaxy("associations".into()),
33                Resource::Galaxy("codex".into()),
34            ]),
35        }
36    }
37}
38
39#[async_trait]
40impl Tool for GraphWalkTool {
41    fn name(&self) -> &str {
42        "graph.walk"
43    }
44    fn gana(&self) -> Gana {
45        Gana::WinnowingBasket
46    }
47    fn effects(&self) -> &EffectRow {
48        &self.effects
49    }
50    fn description(&self) -> &str {
51        "BFS traversal through the association graph from a starting memory"
52    }
53    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
54        let start_id = args
55            .get("start_id")
56            .and_then(|v| v.as_str())
57            .ok_or_else(|| {
58                wm_core::CoreError::InvalidArgs("Missing 'start_id' (UUID) parameter".into())
59            })?;
60        let start_uuid = uuid::Uuid::parse_str(start_id).map_err(|e| {
61            wm_core::CoreError::InvalidArgs(format!("Invalid UUID '{start_id}': {e}"))
62        })?;
63        let max_depth = args
64            .get("max_depth")
65            .and_then(serde_json::Value::as_u64)
66            .unwrap_or(3) as usize;
67        let max_nodes = args
68            .get("max_nodes")
69            .and_then(serde_json::Value::as_u64)
70            .unwrap_or(100) as usize;
71
72        let env = self.store.env();
73        let assoc_store = AssociationStore::open(env)?;
74
75        // BFS
76        let mut visited: HashSet<uuid::Uuid> = HashSet::new();
77        let mut edges: Vec<Value> = Vec::new();
78        let mut queue: VecDeque<(uuid::Uuid, usize)> = VecDeque::new();
79        queue.push_back((start_uuid, 0));
80        visited.insert(start_uuid);
81
82        while let Some((current, depth)) = queue.pop_front() {
83            if depth >= max_depth || visited.len() >= max_nodes {
84                break;
85            }
86
87            // Get outgoing edges
88            let outgoing = assoc_store.find_from(env, current).unwrap_or_default();
89            // Get incoming edges
90            let incoming = assoc_store.find_to(env, current).unwrap_or_default();
91
92            for assoc in outgoing {
93                edges.push(json!({
94                    "source": assoc.source,
95                    "target": assoc.target,
96                    "link_type": assoc.link_type.as_str(),
97                    "weight": assoc.weight,
98                    "depth": depth + 1,
99                }));
100                if visited.insert(assoc.target) {
101                    queue.push_back((assoc.target, depth + 1));
102                }
103            }
104
105            for assoc in incoming {
106                edges.push(json!({
107                    "source": assoc.source,
108                    "target": assoc.target,
109                    "link_type": assoc.link_type.as_str(),
110                    "weight": assoc.weight,
111                    "depth": depth + 1,
112                }));
113                if visited.insert(assoc.source) {
114                    queue.push_back((assoc.source, depth + 1));
115                }
116            }
117        }
118
119        // Fetch memory content for visited nodes
120        let nodes: Vec<Value> = visited
121            .iter()
122            .take(max_nodes)
123            .filter_map(|&id| {
124                // Try each galaxy to find the memory
125                for galaxy in wm_core::Galaxy::memory_galaxies() {
126                    if let Ok(Some(mem)) = self.store.get(galaxy, id) {
127                        return Some(json!({
128                            "id": mem.metadata.id,
129                            "content_preview": mem.content.chars().take(200).collect::<String>(),
130                            "tags": mem.metadata.tags,
131                        }));
132                    }
133                }
134                None
135            })
136            .collect();
137
138        Ok(json!({
139            "status": "success",
140            "start_id": start_uuid,
141            "max_depth": max_depth,
142            "nodes_visited": visited.len(),
143            "edges_traversed": edges.len(),
144            "nodes": nodes,
145            "edges": edges,
146        }))
147    }
148    fn stats(&self) -> &ToolStats {
149        &self.stats
150    }
151}
152
153/// `graph.community` — detect communities using label propagation.
154///
155/// Runs label propagation on the association graph to detect clusters
156/// of tightly connected memories. Each node adopts the label shared by
157/// the majority of its neighbors. Iterates until convergence or max rounds.
158pub struct GraphCommunityTool {
159    store: Arc<MemoryStore>,
160    stats: ToolStats,
161    effects: EffectRow,
162}
163
164impl GraphCommunityTool {
165    pub fn new(store: Arc<MemoryStore>) -> Self {
166        Self {
167            store,
168            stats: ToolStats::default(),
169            effects: EffectRow::read_only(vec![Resource::Galaxy("associations".into())]),
170        }
171    }
172}
173
174#[async_trait]
175impl Tool for GraphCommunityTool {
176    fn name(&self) -> &str {
177        "graph.community"
178    }
179    fn gana(&self) -> Gana {
180        Gana::HairyHead
181    }
182    fn effects(&self) -> &EffectRow {
183        &self.effects
184    }
185    fn description(&self) -> &str {
186        "Detect communities in the association graph using label propagation"
187    }
188    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
189        let max_rounds = args
190            .get("max_rounds")
191            .and_then(serde_json::Value::as_u64)
192            .unwrap_or(10) as usize;
193        let min_community_size = args
194            .get("min_size")
195            .and_then(serde_json::Value::as_u64)
196            .unwrap_or(2) as usize;
197
198        let env = self.store.env();
199        let assoc_store = AssociationStore::open(env)?;
200
201        // Build adjacency list from all associations
202        // We need to scan all associations — use find_from for each known node.
203        // Since we don't have a "scan all" method, we'll scan all galaxies for
204        // memory IDs and build the graph from there.
205        let mut adjacency: HashMap<uuid::Uuid, Vec<uuid::Uuid>> = HashMap::new();
206        let mut all_nodes: HashSet<uuid::Uuid> = HashSet::new();
207
208        for galaxy in wm_core::Galaxy::memory_galaxies() {
209            let memories = self.store.scan(galaxy, 1000)?;
210            for mem in &memories {
211                let from = assoc_store
212                    .find_from(env, mem.metadata.id)
213                    .unwrap_or_default();
214                for assoc in &from {
215                    adjacency
216                        .entry(assoc.source)
217                        .or_default()
218                        .push(assoc.target);
219                    adjacency
220                        .entry(assoc.target)
221                        .or_default()
222                        .push(assoc.source);
223                    all_nodes.insert(assoc.source);
224                    all_nodes.insert(assoc.target);
225                }
226            }
227        }
228
229        if all_nodes.is_empty() {
230            return Ok(json!({
231                "status": "success",
232                "total_nodes": 0,
233                "communities": [],
234                "rounds": 0,
235            }));
236        }
237
238        // Initialize: each node has its own label
239        let nodes: Vec<uuid::Uuid> = all_nodes.iter().copied().collect();
240        let mut labels: HashMap<uuid::Uuid, usize> =
241            nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
242
243        // Label propagation
244        let mut rounds = 0;
245        let mut changed = true;
246        while changed && rounds < max_rounds {
247            changed = false;
248            rounds += 1;
249
250            for &node in &nodes {
251                let neighbors = adjacency.get(&node);
252                if neighbors.is_none_or(std::vec::Vec::is_empty) {
253                    continue;
254                }
255
256                // Count neighbor labels
257                let mut label_counts: HashMap<usize, u32> = HashMap::new();
258                for &neighbor in neighbors.unwrap() {
259                    if let Some(&label) = labels.get(&neighbor) {
260                        *label_counts.entry(label).or_default() += 1;
261                    }
262                }
263
264                // Find majority label
265                if let Some((&best_label, _)) = label_counts.iter().max_by_key(|&(_, &count)| count)
266                {
267                    if labels[&node] != best_label {
268                        labels.insert(node, best_label);
269                        changed = true;
270                    }
271                }
272            }
273        }
274
275        // Group nodes by community
276        let mut communities: HashMap<usize, Vec<uuid::Uuid>> = HashMap::new();
277        for (&node, &label) in &labels {
278            communities.entry(label).or_default().push(node);
279        }
280
281        // Filter by min size and sort by size descending
282        let mut filtered: Vec<(usize, Vec<uuid::Uuid>)> = communities
283            .into_iter()
284            .filter(|(_, members)| members.len() >= min_community_size)
285            .collect();
286        filtered.sort_by_key(|entry| std::cmp::Reverse(entry.1.len()));
287
288        let community_json: Vec<Value> = filtered
289            .iter()
290            .enumerate()
291            .map(|(idx, (_, members))| {
292                json!({
293                    "community_id": idx,
294                    "size": members.len(),
295                    "member_ids": members.iter().take(20).map(std::string::ToString::to_string).collect::<Vec<_>>(),
296                })
297            })
298            .collect();
299
300        Ok(json!({
301            "status": "success",
302            "total_nodes": all_nodes.len(),
303            "total_edges": adjacency.values().map(std::vec::Vec::len).sum::<usize>() / 2,
304            "rounds": rounds,
305            "communities": community_json,
306            "community_count": filtered.len(),
307        }))
308    }
309    fn stats(&self) -> &ToolStats {
310        &self.stats
311    }
312}
313
314/// `graph.propagate` — spread activation through the association graph.
315///
316/// Starting from a seed memory, propagates activation energy through
317/// associated memories. Each hop decays the activation by a factor.
318/// Returns the ranked list of activated memories.
319pub struct GraphPropagateTool {
320    store: Arc<MemoryStore>,
321    stats: ToolStats,
322    effects: EffectRow,
323}
324
325impl GraphPropagateTool {
326    pub fn new(store: Arc<MemoryStore>) -> Self {
327        Self {
328            store,
329            stats: ToolStats::default(),
330            effects: EffectRow::read_only(vec![Resource::Galaxy("associations".into())]),
331        }
332    }
333}
334
335#[async_trait]
336impl Tool for GraphPropagateTool {
337    fn name(&self) -> &str {
338        "graph.propagate"
339    }
340    fn gana(&self) -> Gana {
341        Gana::WinnowingBasket
342    }
343    fn effects(&self) -> &EffectRow {
344        &self.effects
345    }
346    fn description(&self) -> &str {
347        "Spread activation through the association graph from seed memories"
348    }
349    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
350        let seed_ids = args
351            .get("seed_ids")
352            .and_then(|v| v.as_array())
353            .ok_or_else(|| {
354                wm_core::CoreError::InvalidArgs("Missing 'seed_ids' array parameter".into())
355            })?;
356        let max_hops = args
357            .get("max_hops")
358            .and_then(serde_json::Value::as_u64)
359            .unwrap_or(3) as usize;
360        let decay = args
361            .get("decay")
362            .and_then(serde_json::Value::as_f64)
363            .unwrap_or(0.5) as f32;
364        let min_activation = args
365            .get("min_activation")
366            .and_then(serde_json::Value::as_f64)
367            .unwrap_or(0.05) as f32;
368
369        // Parse seed UUIDs
370        let mut seeds: Vec<(uuid::Uuid, f32)> = Vec::new();
371        for seed in seed_ids {
372            if let Some(id_str) = seed.as_str() {
373                if let Ok(uuid) = uuid::Uuid::parse_str(id_str) {
374                    seeds.push((uuid, 1.0));
375                }
376            }
377        }
378        if seeds.is_empty() {
379            return Err(wm_core::CoreError::InvalidArgs(
380                "No valid seed UUIDs provided".into(),
381            ));
382        }
383
384        let env = self.store.env();
385        let assoc_store = AssociationStore::open(env)?;
386
387        // Activation spreading (BFS with decay)
388        let mut activation: HashMap<uuid::Uuid, f32> = HashMap::new();
389        let mut visited: HashSet<uuid::Uuid> = HashSet::new();
390
391        // Initialize seeds
392        for (seed_id, initial_activation) in &seeds {
393            activation.insert(*seed_id, *initial_activation);
394        }
395
396        let mut current_front: Vec<(uuid::Uuid, f32)> = seeds.clone();
397
398        for hop in 0..max_hops {
399            if current_front.is_empty() {
400                break;
401            }
402            let mut next_front: Vec<(uuid::Uuid, f32)> = Vec::new();
403
404            for (node, node_activation) in &current_front {
405                if visited.contains(node) {
406                    continue;
407                }
408                visited.insert(*node);
409
410                // Get neighbors
411                let outgoing = assoc_store.find_from(env, *node).unwrap_or_default();
412                let incoming = assoc_store.find_to(env, *node).unwrap_or_default();
413
414                let mut propagate = |neighbor_id: uuid::Uuid, weight: f32| {
415                    if !visited.contains(&neighbor_id) {
416                        let propagated = node_activation * weight * decay;
417                        if propagated >= min_activation {
418                            let entry = activation.entry(neighbor_id).or_insert(0.0);
419                            // Take max activation (not sum, to avoid overflow)
420                            if propagated > *entry {
421                                *entry = propagated;
422                            }
423                            next_front.push((neighbor_id, propagated));
424                        }
425                    }
426                };
427
428                for assoc in &outgoing {
429                    propagate(assoc.target, assoc.weight);
430                }
431                for assoc in &incoming {
432                    propagate(assoc.source, assoc.weight);
433                }
434            }
435
436            current_front = next_front;
437            let _ = hop; // suppress unused warning
438        }
439
440        // Rank by activation (descending)
441        let mut ranked: Vec<(uuid::Uuid, f32)> = activation
442            .into_iter()
443            .filter(|(_, a)| *a >= min_activation)
444            .collect();
445        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
446
447        // Fetch memory content for top results
448        let results: Vec<Value> = ranked
449            .iter()
450            .take(50)
451            .map(|(id, activation)| {
452                let content_preview = {
453                    let mut found = None;
454                    for galaxy in wm_core::Galaxy::memory_galaxies() {
455                        if let Ok(Some(mem)) = self.store.get(galaxy, *id) {
456                            found = Some(mem.content.chars().take(200).collect::<String>());
457                            break;
458                        }
459                    }
460                    found.unwrap_or_default()
461                };
462                json!({
463                    "id": id,
464                    "activation": (f64::from(*activation) * 10000.0).round() / 10000.0,
465                    "content_preview": content_preview,
466                })
467            })
468            .collect();
469
470        Ok(json!({
471            "status": "success",
472            "seeds": seeds.len(),
473            "max_hops": max_hops,
474            "decay": decay,
475            "activated_nodes": ranked.len(),
476            "results": results,
477        }))
478    }
479    fn stats(&self) -> &ToolStats {
480        &self.stats
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use tempfile::tempdir;
488    use wm_memory::{Association, LinkType, Memory};
489
490    fn open_store() -> (tempfile::TempDir, MemoryStore) {
491        let tmp = tempdir().unwrap();
492        let store = MemoryStore::open_default(tmp.path()).unwrap();
493        (tmp, store)
494    }
495
496    fn setup_graph(store: &MemoryStore) -> Vec<uuid::Uuid> {
497        let env = store.env();
498        let assoc_store = AssociationStore::open(env).unwrap();
499        let galaxy = wm_core::Galaxy::Codex;
500
501        // Create 5 memories: A -> B -> C -> D, A -> E
502        let mut ids = Vec::new();
503        for i in 0..5 {
504            let mem = Memory::new(galaxy, format!("Memory {i}"));
505            store.put(galaxy, &mem).unwrap();
506            ids.push(mem.metadata.id);
507        }
508
509        // Create associations: A->B, B->C, C->D, A->E
510        assoc_store
511            .put(
512                env,
513                &Association::new(ids[0], ids[1], LinkType::Related, 0.8),
514            )
515            .unwrap();
516        assoc_store
517            .put(
518                env,
519                &Association::new(ids[1], ids[2], LinkType::Related, 0.7),
520            )
521            .unwrap();
522        assoc_store
523            .put(
524                env,
525                &Association::new(ids[2], ids[3], LinkType::Related, 0.6),
526            )
527            .unwrap();
528        assoc_store
529            .put(
530                env,
531                &Association::new(ids[0], ids[4], LinkType::Related, 0.5),
532            )
533            .unwrap();
534
535        ids
536    }
537
538    #[tokio::test]
539    async fn graph_walk_traverses_bfs() {
540        let (_tmp, store) = open_store();
541        let ids = setup_graph(&store);
542
543        let tool = GraphWalkTool::new(Arc::new(store));
544        let result = tool
545            .call(
546                &mut Context::default(),
547                json!({"start_id": ids[0].to_string(), "max_depth": 3}),
548            )
549            .await
550            .unwrap();
551        let obj = result.as_object().unwrap();
552        assert_eq!(obj["status"], "success");
553        assert!(obj["nodes_visited"].as_u64().unwrap() >= 4);
554        assert!(obj["edges_traversed"].as_u64().unwrap() >= 4);
555    }
556
557    #[tokio::test]
558    async fn graph_walk_invalid_uuid_errors() {
559        let (_tmp, store) = open_store();
560        let tool = GraphWalkTool::new(Arc::new(store));
561        let result = tool
562            .call(&mut Context::default(), json!({"start_id": "not-a-uuid"}))
563            .await;
564        assert!(result.is_err());
565    }
566
567    #[tokio::test]
568    async fn graph_walk_missing_start_id_errors() {
569        let (_tmp, store) = open_store();
570        let tool = GraphWalkTool::new(Arc::new(store));
571        let result = tool.call(&mut Context::default(), json!({})).await;
572        assert!(result.is_err());
573    }
574
575    #[tokio::test]
576    async fn graph_walk_depth_limit_works() {
577        let (_tmp, store) = open_store();
578        let ids = setup_graph(&store);
579
580        let tool = GraphWalkTool::new(Arc::new(store));
581        let result = tool
582            .call(
583                &mut Context::default(),
584                json!({"start_id": ids[0].to_string(), "max_depth": 1}),
585            )
586            .await
587            .unwrap();
588        let obj = result.as_object().unwrap();
589        // With depth 1, should visit A, B, E (direct neighbors)
590        assert!(obj["nodes_visited"].as_u64().unwrap() >= 3);
591        assert!(obj["nodes_visited"].as_u64().unwrap() <= 3);
592    }
593
594    #[tokio::test]
595    async fn graph_community_detects_clusters() {
596        let (_tmp, store) = open_store();
597        let _ids = setup_graph(&store);
598
599        let tool = GraphCommunityTool::new(Arc::new(store));
600        let result = tool
601            .call(
602                &mut Context::default(),
603                json!({"max_rounds": 20, "min_size": 2}),
604            )
605            .await
606            .unwrap();
607        let obj = result.as_object().unwrap();
608        assert_eq!(obj["status"], "success");
609        assert_eq!(obj["total_nodes"], 5);
610        // With this small graph, all 5 should converge to one community
611        let communities = obj["communities"].as_array().unwrap();
612        assert!(!communities.is_empty());
613    }
614
615    #[tokio::test]
616    async fn graph_community_empty_graph() {
617        let (_tmp, store) = open_store();
618        let tool = GraphCommunityTool::new(Arc::new(store));
619        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
620        let obj = result.as_object().unwrap();
621        assert_eq!(obj["total_nodes"], 0);
622        assert_eq!(obj["communities"].as_array().unwrap().len(), 0);
623    }
624
625    #[tokio::test]
626    async fn graph_propagate_spreads_activation() {
627        let (_tmp, store) = open_store();
628        let ids = setup_graph(&store);
629
630        let tool = GraphPropagateTool::new(Arc::new(store));
631        let result = tool
632            .call(
633                &mut Context::default(),
634                json!({
635                    "seed_ids": [ids[0].to_string()],
636                    "max_hops": 3,
637                    "decay": 0.5,
638                }),
639            )
640            .await
641            .unwrap();
642        let obj = result.as_object().unwrap();
643        assert_eq!(obj["status"], "success");
644        assert!(obj["activated_nodes"].as_u64().unwrap() >= 4);
645        // The seed should have highest activation
646        let results = obj["results"].as_array().unwrap();
647        assert_eq!(results[0]["id"], ids[0].to_string());
648    }
649
650    #[tokio::test]
651    async fn graph_propagate_missing_seeds_errors() {
652        let (_tmp, store) = open_store();
653        let tool = GraphPropagateTool::new(Arc::new(store));
654        let result = tool.call(&mut Context::default(), json!({})).await;
655        assert!(result.is_err());
656    }
657
658    #[tokio::test]
659    async fn graph_propagate_invalid_seed_errors() {
660        let (_tmp, store) = open_store();
661        let tool = GraphPropagateTool::new(Arc::new(store));
662        let result = tool
663            .call(&mut Context::default(), json!({"seed_ids": ["not-a-uuid"]}))
664            .await;
665        assert!(result.is_err());
666    }
667
668    #[tokio::test]
669    async fn graph_tool_names_are_correct() {
670        let store = Arc::new(open_store().1);
671        assert_eq!(GraphWalkTool::new(store.clone()).name(), "graph.walk");
672        assert_eq!(
673            GraphCommunityTool::new(store.clone()).name(),
674            "graph.community"
675        );
676        assert_eq!(GraphPropagateTool::new(store).name(), "graph.propagate");
677    }
678
679    #[tokio::test]
680    async fn graph_tool_ganas_are_correct() {
681        let store = Arc::new(open_store().1);
682        assert_eq!(
683            GraphWalkTool::new(store.clone()).gana(),
684            Gana::WinnowingBasket
685        );
686        assert_eq!(
687            GraphCommunityTool::new(store.clone()).gana(),
688            Gana::HairyHead
689        );
690        assert_eq!(GraphPropagateTool::new(store).gana(), Gana::WinnowingBasket);
691    }
692}