1#![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
16pub 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 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 let outgoing = assoc_store.find_from(env, current).unwrap_or_default();
89 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 let nodes: Vec<Value> = visited
121 .iter()
122 .take(max_nodes)
123 .filter_map(|&id| {
124 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
153pub 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 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 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 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 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 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 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 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
314pub 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 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 let mut activation: HashMap<uuid::Uuid, f32> = HashMap::new();
389 let mut visited: HashSet<uuid::Uuid> = HashSet::new();
390
391 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 ¤t_front {
405 if visited.contains(node) {
406 continue;
407 }
408 visited.insert(*node);
409
410 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 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; }
439
440 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 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 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 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 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 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 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}