Skip to main content

wm_tools/expansion/
network.rs

1//! Network tools — association mining, pattern detection, network analysis.
2//!
3//! Tools:
4//! - `association.mine` — Cross-galaxy association mining using keyword overlap
5//! - `pattern.detect` — Detect structural patterns (hubs, bridges, chains) in the association graph
6//! - `emergence.report` — Detailed emergence analysis from tag frequency distribution
7//! - `network.stats` — Global network statistics (nodes, edges, density, degree distribution)
8//! - `network.centrality` — Degree centrality metrics for memories in the association graph
9//! - `network.clusters` — Identify connected components / clusters in the association graph
10
11#![forbid(unsafe_code)]
12
13use async_trait::async_trait;
14
15use serde_json::{Value, json};
16use std::collections::{HashMap, HashSet};
17use std::sync::Arc;
18use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
19use wm_memory::{AssociationStore, MemoryStore};
20
21use super::common::{galaxy_name, parse_galaxy};
22
23/// `association.mine` — Cross-galaxy association mining.
24///
25/// Scans memories across all memory-storing galaxies and proposes associations
26/// based on keyword overlap (Jaccard similarity). Unlike `memory.associate_mine`
27/// which works within a single galaxy, this tool works across all galaxies.
28pub struct AssociationMineTool {
29    store: Arc<MemoryStore>,
30    stats: ToolStats,
31    effects: EffectRow,
32}
33
34impl AssociationMineTool {
35    pub fn new(store: Arc<MemoryStore>) -> Self {
36        Self {
37            store,
38            stats: ToolStats::default(),
39            effects: EffectRow {
40                writes: vec![Resource::Galaxy("associations".into())],
41                ..Default::default()
42            },
43        }
44    }
45}
46
47#[async_trait]
48impl Tool for AssociationMineTool {
49    fn name(&self) -> &str {
50        "association.mine"
51    }
52    fn gana(&self) -> Gana {
53        Gana::Net
54    }
55    fn effects(&self) -> &EffectRow {
56        &self.effects
57    }
58    fn description(&self) -> &str {
59        "Mine cross-galaxy associations using keyword overlap analysis"
60    }
61    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
62        let min_strength = args
63            .get("min_strength")
64            .and_then(Value::as_f64)
65            .unwrap_or(0.3) as f32;
66        let limit_per_galaxy = args
67            .get("limit")
68            .and_then(serde_json::Value::as_u64)
69            .unwrap_or(100) as usize;
70        let max_comparisons = args
71            .get("max_comparisons")
72            .and_then(serde_json::Value::as_u64)
73            .unwrap_or(50_000) as usize;
74
75        let env = self.store.env();
76        let assoc_store = AssociationStore::open(env)?;
77
78        // Collect memories from all memory-storing galaxies
79        let mut all_memories: Vec<(Galaxy, &str, wm_memory::Memory)> = Vec::new();
80        for galaxy in Galaxy::memory_galaxies() {
81            let mems = self.store.scan(galaxy, limit_per_galaxy)?;
82            for mem in mems {
83                all_memories.push((galaxy, galaxy_name(galaxy), mem));
84            }
85        }
86
87        let mut proposed = 0u32;
88        let mut cross_galaxy_links = 0u32;
89        let mut same_galaxy_links = 0u32;
90        let mut comparisons = 0u32;
91
92        for i in 0..all_memories.len() {
93            if comparisons as usize >= max_comparisons {
94                break;
95            }
96            for j in (i + 1)..all_memories.len() {
97                comparisons += 1;
98                if comparisons as usize >= max_comparisons {
99                    break;
100                }
101                let (g1, _, ref a) = all_memories[i];
102                let (g2, _, ref b) = all_memories[j];
103                let a_words: HashSet<&str> = a.content.split_whitespace().collect();
104                let b_words: HashSet<&str> = b.content.split_whitespace().collect();
105                let intersection = a_words.intersection(&b_words).count();
106                let union = a_words.union(&b_words).count();
107                if union > 0 && intersection > 2 {
108                    let strength = intersection as f32 / union as f32;
109                    if strength > min_strength {
110                        // Check if association already exists
111                        if assoc_store
112                            .get(env, a.metadata.id, b.metadata.id)
113                            .ok()
114                            .flatten()
115                            .is_none()
116                        {
117                            let assoc = wm_memory::Association::new(
118                                a.metadata.id,
119                                b.metadata.id,
120                                wm_memory::LinkType::Related,
121                                strength,
122                            );
123                            let _ = assoc_store.put(env, &assoc);
124                            proposed += 1;
125                            if g1 == g2 {
126                                same_galaxy_links += 1;
127                            } else {
128                                cross_galaxy_links += 1;
129                            }
130                        }
131                    }
132                }
133            }
134        }
135
136        Ok(json!({
137            "status": "success",
138            "memories_scanned": all_memories.len(),
139            "proposed_associations": proposed,
140            "cross_galaxy_links": cross_galaxy_links,
141            "same_galaxy_links": same_galaxy_links,
142            "min_strength": min_strength,
143            "comparisons": comparisons,
144            "truncated": comparisons as usize >= max_comparisons,
145        }))
146    }
147    fn stats(&self) -> &ToolStats {
148        &self.stats
149    }
150}
151
152/// `pattern.detect` — Detect structural patterns in the association graph.
153///
154/// Identifies hubs (high-degree nodes), bridges (nodes connecting clusters),
155/// and chains (temporal sequences) in the association network.
156pub struct PatternDetectTool {
157    store: Arc<MemoryStore>,
158    stats: ToolStats,
159    effects: EffectRow,
160}
161
162impl PatternDetectTool {
163    pub fn new(store: Arc<MemoryStore>) -> Self {
164        Self {
165            store,
166            stats: ToolStats::default(),
167            effects: EffectRow::pure(),
168        }
169    }
170}
171
172#[async_trait]
173impl Tool for PatternDetectTool {
174    fn name(&self) -> &str {
175        "pattern.detect"
176    }
177    fn gana(&self) -> Gana {
178        Gana::Net
179    }
180    fn effects(&self) -> &EffectRow {
181        &self.effects
182    }
183    fn description(&self) -> &str {
184        "Detect structural patterns (hubs, bridges, chains) in the association graph"
185    }
186    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
187        let top_k = args
188            .get("top_k")
189            .and_then(serde_json::Value::as_u64)
190            .unwrap_or(10) as usize;
191
192        let env = self.store.env();
193        let assoc_store = AssociationStore::open(env)?;
194
195        // Build degree map: UUID -> (in_degree, out_degree)
196        let mut degree_map: HashMap<uuid::Uuid, (u32, u32)> = HashMap::new();
197        let mut all_assocs: Vec<wm_memory::Association> = Vec::new();
198
199        // Scan all associations by iterating through all memories
200        for galaxy in Galaxy::memory_galaxies() {
201            let mems = self.store.scan(galaxy, 10000)?;
202            for mem in mems {
203                let outgoing = assoc_store
204                    .find_from(env, mem.metadata.id)
205                    .unwrap_or_default();
206                let incoming = assoc_store
207                    .find_to(env, mem.metadata.id)
208                    .unwrap_or_default();
209                let out_deg = outgoing.len() as u32;
210                let in_deg = incoming.len() as u32;
211                if out_deg > 0 || in_deg > 0 {
212                    degree_map.insert(mem.metadata.id, (in_deg, out_deg));
213                }
214                all_assocs.extend(outgoing);
215                all_assocs.extend(incoming);
216            }
217        }
218
219        // Detect hubs: nodes with high total degree
220        let mut hubs: Vec<(uuid::Uuid, u32)> = degree_map
221            .iter()
222            .map(|(id, (ind, outd))| (*id, ind + outd))
223            .collect();
224        hubs.sort_by_key(|x| std::cmp::Reverse(x.1));
225        let top_hubs: Vec<Value> = hubs
226            .iter()
227            .take(top_k)
228            .map(|(id, deg)| {
229                json!({
230                    "memory_id": id,
231                    "total_degree": deg,
232                })
233            })
234            .collect();
235
236        // Detect chains: temporal sequences (A -> B -> C where link_type is Temporal)
237        let mut chains: Vec<Value> = Vec::new();
238        let temporal_assocs: Vec<&wm_memory::Association> = all_assocs
239            .iter()
240            .filter(|a| a.link_type == wm_memory::LinkType::Temporal)
241            .collect();
242        let mut temporal_map: HashMap<uuid::Uuid, uuid::Uuid> = HashMap::new();
243        for a in &temporal_assocs {
244            temporal_map.insert(a.source, a.target);
245        }
246        for a in &temporal_assocs {
247            let mut chain = vec![a.source, a.target];
248            let mut current = a.target;
249            while let Some(&next) = temporal_map.get(&current) {
250                if chain.contains(&next) {
251                    break;
252                }
253                chain.push(next);
254                current = next;
255            }
256            if chain.len() >= 3 {
257                chains.push(json!({
258                    "chain": chain.iter().map(std::string::ToString::to_string).collect::<Vec<_>>(),
259                    "length": chain.len(),
260                }));
261            }
262        }
263
264        // Detect bridges: nodes that appear in many cross-galaxy associations
265        let mut bridge_counts: HashMap<uuid::Uuid, u32> = HashMap::new();
266        for a in &all_assocs {
267            let source_galaxy = self.find_memory_galaxy(a.source);
268            let target_galaxy = self.find_memory_galaxy(a.target);
269            if let (Some(sg), Some(tg)) = (source_galaxy, target_galaxy) {
270                if sg != tg {
271                    *bridge_counts.entry(a.source).or_insert(0) += 1;
272                    *bridge_counts.entry(a.target).or_insert(0) += 1;
273                }
274            }
275        }
276        let mut bridges: Vec<(uuid::Uuid, u32)> = bridge_counts.into_iter().collect();
277        bridges.sort_by_key(|x| std::cmp::Reverse(x.1));
278        let top_bridges: Vec<Value> = bridges
279            .iter()
280            .take(top_k)
281            .map(|(id, count)| {
282                json!({
283                    "memory_id": id,
284                    "cross_galaxy_links": count,
285                })
286            })
287            .collect();
288
289        Ok(json!({
290            "status": "success",
291            "total_nodes": degree_map.len(),
292            "total_edges": all_assocs.len(),
293            "hubs": top_hubs,
294            "chains": chains,
295            "bridges": top_bridges,
296        }))
297    }
298    fn stats(&self) -> &ToolStats {
299        &self.stats
300    }
301}
302
303impl PatternDetectTool {
304    fn find_memory_galaxy(&self, id: uuid::Uuid) -> Option<Galaxy> {
305        Galaxy::memory_galaxies()
306            .into_iter()
307            .find(|&galaxy| self.store.get(galaxy, id).ok().flatten().is_some())
308    }
309}
310
311/// `emergence.report` — Detailed emergence analysis from tag frequency distribution.
312///
313/// Scans all memories and computes tag frequency distribution, identifying
314/// emerging tags (frequency increasing), dominant tags (high frequency),
315/// and declining tags (low frequency relative to total).
316pub struct EmergenceReportTool {
317    store: Arc<MemoryStore>,
318    stats: ToolStats,
319    effects: EffectRow,
320}
321
322impl EmergenceReportTool {
323    pub fn new(store: Arc<MemoryStore>) -> Self {
324        Self {
325            store,
326            stats: ToolStats::default(),
327            effects: EffectRow::pure(),
328        }
329    }
330}
331
332#[async_trait]
333impl Tool for EmergenceReportTool {
334    fn name(&self) -> &str {
335        "emergence.report"
336    }
337    fn gana(&self) -> Gana {
338        Gana::Net
339    }
340    fn effects(&self) -> &EffectRow {
341        &self.effects
342    }
343    fn description(&self) -> &str {
344        "Detailed emergence analysis with tag frequency distribution and trend detection"
345    }
346    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
347        let mut tag_counts: HashMap<String, usize> = HashMap::new();
348        let mut total_memories = 0usize;
349
350        for galaxy in Galaxy::memory_galaxies() {
351            let mems = self.store.scan(galaxy, 10000)?;
352            for mem in mems {
353                total_memories += 1;
354                for tag in &mem.metadata.tags {
355                    *tag_counts.entry(tag.clone()).or_insert(0) += 1;
356                }
357            }
358        }
359
360        let total_tags: usize = tag_counts.values().sum();
361        let unique_tags = tag_counts.len();
362
363        // Sort tags by frequency
364        let mut sorted_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
365        sorted_tags.sort_by_key(|x| std::cmp::Reverse(x.1));
366
367        let dominant: Vec<Value> = sorted_tags
368            .iter()
369            .filter(|(_, count)| *count as f64 / total_memories.max(1) as f64 > 0.1)
370            .take(20)
371            .map(|(tag, count)| {
372                json!({
373                    "tag": tag,
374                    "count": count,
375                    "frequency": (*count as f64 / total_memories.max(1) as f64 * 100.0).round() / 100.0,
376                })
377            })
378            .collect();
379
380        let emerging: Vec<Value> = sorted_tags
381            .iter()
382            .filter(|(_, count)| *count >= 2 && *count <= 5)
383            .take(20)
384            .map(|(tag, count)| {
385                json!({
386                    "tag": tag,
387                    "count": count,
388                })
389            })
390            .collect();
391
392        let rare: Vec<Value> = sorted_tags
393            .iter()
394            .filter(|(_, count)| *count == 1)
395            .take(20)
396            .map(|(tag, _)| json!({"tag": tag}))
397            .collect();
398
399        Ok(json!({
400            "status": "success",
401            "total_memories": total_memories,
402            "unique_tags": unique_tags,
403            "total_tag_instances": total_tags,
404            "dominant_tags": dominant,
405            "emerging_tags": emerging,
406            "rare_tags": rare,
407            "tag_diversity": (unique_tags as f64 / total_memories.max(1) as f64 * 100.0).round() / 100.0,
408        }))
409    }
410    fn stats(&self) -> &ToolStats {
411        &self.stats
412    }
413}
414
415/// `network.stats` — Global network statistics.
416///
417/// Computes nodes, edges, density, degree distribution, and link type breakdown
418/// for the entire association graph.
419pub struct NetworkStatsTool {
420    store: Arc<MemoryStore>,
421    stats: ToolStats,
422    effects: EffectRow,
423}
424
425impl NetworkStatsTool {
426    pub fn new(store: Arc<MemoryStore>) -> Self {
427        Self {
428            store,
429            stats: ToolStats::default(),
430            effects: EffectRow::pure(),
431        }
432    }
433}
434
435#[async_trait]
436impl Tool for NetworkStatsTool {
437    fn name(&self) -> &str {
438        "network.stats"
439    }
440    fn gana(&self) -> Gana {
441        Gana::Net
442    }
443    fn effects(&self) -> &EffectRow {
444        &self.effects
445    }
446    fn description(&self) -> &str {
447        "Global association network statistics (nodes, edges, density, degree distribution)"
448    }
449    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
450        let env = self.store.env();
451        let assoc_store = AssociationStore::open(env)?;
452
453        let total_edges = assoc_store.count(env)?;
454
455        // Build degree map
456        let mut degree_map: HashMap<uuid::Uuid, (u32, u32)> = HashMap::new();
457        let mut link_type_counts: HashMap<&'static str, u32> = HashMap::new();
458        let mut total_weight: f32 = 0.0;
459
460        for galaxy in Galaxy::memory_galaxies() {
461            let mems = self.store.scan(galaxy, 10000)?;
462            for mem in mems {
463                let outgoing = assoc_store
464                    .find_from(env, mem.metadata.id)
465                    .unwrap_or_default();
466                let incoming = assoc_store
467                    .find_to(env, mem.metadata.id)
468                    .unwrap_or_default();
469                let out_deg = outgoing.len() as u32;
470                let in_deg = incoming.len() as u32;
471                if out_deg > 0 || in_deg > 0 {
472                    let entry = degree_map.entry(mem.metadata.id).or_insert((0, 0));
473                    entry.0 += in_deg;
474                    entry.1 += out_deg;
475                }
476                for a in &outgoing {
477                    *link_type_counts.entry(a.link_type.as_str()).or_insert(0) += 1;
478                    total_weight += a.weight;
479                }
480            }
481        }
482
483        let total_nodes = degree_map.len();
484        let max_possible_edges = if total_nodes > 1 {
485            total_nodes * (total_nodes - 1)
486        } else {
487            0
488        };
489        let density = if max_possible_edges > 0 {
490            total_edges as f64 / max_possible_edges as f64
491        } else {
492            0.0
493        };
494
495        // Degree distribution
496        let mut degree_distribution: Vec<u32> =
497            degree_map.values().map(|(ind, outd)| ind + outd).collect();
498        degree_distribution.sort_unstable();
499        let avg_degree = if degree_distribution.is_empty() {
500            0.0
501        } else {
502            f64::from(degree_distribution.iter().sum::<u32>()) / degree_distribution.len() as f64
503        };
504        let max_degree = degree_distribution.iter().copied().max().unwrap_or(0);
505
506        let link_breakdown: Vec<Value> = link_type_counts
507            .iter()
508            .map(|(lt, count)| {
509                json!({
510                    "link_type": lt,
511                    "count": count,
512                })
513            })
514            .collect();
515
516        let avg_weight = if total_edges > 0 {
517            total_weight / total_edges as f32
518        } else {
519            0.0
520        };
521
522        Ok(json!({
523            "status": "success",
524            "nodes": total_nodes,
525            "edges": total_edges,
526            "density": (density * 10000.0).round() / 10000.0,
527            "avg_degree": (avg_degree * 100.0).round() / 100.0,
528            "max_degree": max_degree,
529            "avg_weight": (avg_weight * 100.0).round() / 100.0,
530            "link_type_breakdown": link_breakdown,
531        }))
532    }
533    fn stats(&self) -> &ToolStats {
534        &self.stats
535    }
536}
537
538/// `network.centrality` — Degree centrality metrics.
539///
540/// Computes in-degree, out-degree, and total degree centrality for each node
541/// in the association graph. Returns top-K nodes by centrality.
542pub struct NetworkCentralityTool {
543    store: Arc<MemoryStore>,
544    stats: ToolStats,
545    effects: EffectRow,
546}
547
548impl NetworkCentralityTool {
549    pub fn new(store: Arc<MemoryStore>) -> Self {
550        Self {
551            store,
552            stats: ToolStats::default(),
553            effects: EffectRow::pure(),
554        }
555    }
556}
557
558#[async_trait]
559impl Tool for NetworkCentralityTool {
560    fn name(&self) -> &str {
561        "network.centrality"
562    }
563    fn gana(&self) -> Gana {
564        Gana::Net
565    }
566    fn effects(&self) -> &EffectRow {
567        &self.effects
568    }
569    fn description(&self) -> &str {
570        "Compute degree centrality metrics for memories in the association graph"
571    }
572    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
573        let top_k = args
574            .get("top_k")
575            .and_then(serde_json::Value::as_u64)
576            .unwrap_or(20) as usize;
577        let galaxy_str = args.get("galaxy").and_then(Value::as_str);
578
579        let env = self.store.env();
580        let assoc_store = AssociationStore::open(env)?;
581
582        let galaxies: Vec<Galaxy> = match galaxy_str {
583            Some(g) => vec![parse_galaxy(g)?],
584            None => Galaxy::memory_galaxies().to_vec(),
585        };
586
587        let mut centrality: Vec<(uuid::Uuid, u32, u32, u32)> = Vec::new();
588
589        for galaxy in &galaxies {
590            let mems = self.store.scan(*galaxy, 10000)?;
591            for mem in mems {
592                let outgoing = assoc_store
593                    .find_from(env, mem.metadata.id)
594                    .unwrap_or_default();
595                let incoming = assoc_store
596                    .find_to(env, mem.metadata.id)
597                    .unwrap_or_default();
598                let out_deg = outgoing.len() as u32;
599                let in_deg = incoming.len() as u32;
600                if out_deg > 0 || in_deg > 0 {
601                    centrality.push((mem.metadata.id, in_deg, out_deg, in_deg + out_deg));
602                }
603            }
604        }
605
606        centrality.sort_by_key(|x| std::cmp::Reverse(x.3));
607
608        let max_degree = centrality.first().map_or(1, |c| c.3);
609
610        let top_nodes: Vec<Value> = centrality
611            .iter()
612            .take(top_k)
613            .map(|(id, ind, outd, total)| {
614                let centrality_score = if max_degree > 0 {
615                    f64::from(*total) / f64::from(max_degree)
616                } else {
617                    0.0
618                };
619                json!({
620                    "memory_id": id,
621                    "in_degree": ind,
622                    "out_degree": outd,
623                    "total_degree": total,
624                    "centrality": (centrality_score * 1000.0).round() / 1000.0,
625                })
626            })
627            .collect();
628
629        Ok(json!({
630            "status": "success",
631            "total_nodes_with_edges": centrality.len(),
632            "max_degree": max_degree,
633            "top_nodes": top_nodes,
634        }))
635    }
636    fn stats(&self) -> &ToolStats {
637        &self.stats
638    }
639}
640
641/// `network.clusters` — Identify connected components in the association graph.
642///
643/// Uses Union-Find to identify clusters of memories connected by associations.
644/// Returns cluster sizes, largest clusters, and isolated node count.
645pub struct NetworkClustersTool {
646    store: Arc<MemoryStore>,
647    stats: ToolStats,
648    effects: EffectRow,
649}
650
651impl NetworkClustersTool {
652    pub fn new(store: Arc<MemoryStore>) -> Self {
653        Self {
654            store,
655            stats: ToolStats::default(),
656            effects: EffectRow::pure(),
657        }
658    }
659}
660
661#[async_trait]
662impl Tool for NetworkClustersTool {
663    fn name(&self) -> &str {
664        "network.clusters"
665    }
666    fn gana(&self) -> Gana {
667        Gana::Net
668    }
669    fn effects(&self) -> &EffectRow {
670        &self.effects
671    }
672    fn description(&self) -> &str {
673        "Identify connected components and clusters in the association graph"
674    }
675    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
676        let env = self.store.env();
677        let assoc_store = AssociationStore::open(env)?;
678
679        // Collect all nodes and edges
680        let mut node_set: HashSet<uuid::Uuid> = HashSet::new();
681        let mut edges: Vec<(uuid::Uuid, uuid::Uuid)> = Vec::new();
682
683        for galaxy in Galaxy::memory_galaxies() {
684            let mems = self.store.scan(galaxy, 10000)?;
685            for mem in mems {
686                let outgoing = assoc_store
687                    .find_from(env, mem.metadata.id)
688                    .unwrap_or_default();
689                for a in outgoing {
690                    node_set.insert(a.source);
691                    node_set.insert(a.target);
692                    edges.push((a.source, a.target));
693                }
694            }
695        }
696
697        // Union-Find
698        let mut parent: HashMap<uuid::Uuid, uuid::Uuid> = HashMap::new();
699        for &node in &node_set {
700            parent.insert(node, node);
701        }
702
703        fn find(parent: &mut HashMap<uuid::Uuid, uuid::Uuid>, x: uuid::Uuid) -> uuid::Uuid {
704            let mut current = x;
705            while parent[&current] != current {
706                let p = parent[&current];
707                parent.insert(current, p);
708                current = p;
709            }
710            current
711        }
712
713        for (a, b) in &edges {
714            let ra = find(&mut parent, *a);
715            let rb = find(&mut parent, *b);
716            if ra != rb {
717                parent.insert(ra, rb);
718            }
719        }
720
721        // Count cluster sizes
722        let mut cluster_sizes: HashMap<uuid::Uuid, usize> = HashMap::new();
723        for &node in &node_set {
724            let root = find(&mut parent, node);
725            *cluster_sizes.entry(root).or_insert(0) += 1;
726        }
727
728        let mut sizes: Vec<usize> = cluster_sizes.values().copied().collect();
729        sizes.sort_by(|a, b| b.cmp(a));
730
731        let num_clusters = sizes.len();
732        let largest_cluster = sizes.first().copied().unwrap_or(0);
733        let isolated_nodes = sizes.iter().filter(|&&s| s == 1).count();
734        let multi_node_clusters = sizes.iter().filter(|&&s| s > 1).count();
735
736        let top_clusters: Vec<Value> = sizes
737            .iter()
738            .take(10)
739            .enumerate()
740            .map(|(i, &size)| {
741                json!({
742                    "cluster_rank": i + 1,
743                    "size": size,
744                })
745            })
746            .collect();
747
748        Ok(json!({
749            "status": "success",
750            "total_nodes": node_set.len(),
751            "total_edges": edges.len(),
752            "num_clusters": num_clusters,
753            "largest_cluster_size": largest_cluster,
754            "isolated_nodes": isolated_nodes,
755            "multi_node_clusters": multi_node_clusters,
756            "top_clusters": top_clusters,
757        }))
758    }
759    fn stats(&self) -> &ToolStats {
760        &self.stats
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use tempfile::tempdir;
768
769    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
770        let tmp = tempdir().unwrap();
771        let store = MemoryStore::open_default(tmp.path()).unwrap();
772        (tmp, Arc::new(store))
773    }
774
775    fn make_memory(galaxy: Galaxy, content: &str, tags: &[&str]) -> wm_memory::Memory {
776        let mut mem = wm_memory::Memory::new(galaxy, content.to_string());
777        mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
778        mem
779    }
780
781    #[tokio::test]
782    async fn association_mine_basic() {
783        let (_tmp, store) = open_store();
784        let tool = AssociationMineTool::new(store.clone());
785
786        let m1 = make_memory(
787            Galaxy::Codex,
788            "rust is a fast systems programming language",
789            &["rust", "programming"],
790        );
791        let m2 = make_memory(
792            Galaxy::Codex,
793            "rust is a safe systems programming language",
794            &["rust", "programming"],
795        );
796        store.put(Galaxy::Codex, &m1).unwrap();
797        store.put(Galaxy::Codex, &m2).unwrap();
798
799        let mut ctx = Context::new(wm_core::BrainWave::Beta);
800        let result = tool.call(&mut ctx, json!({})).await.unwrap();
801        assert_eq!(result["status"], "success");
802        assert!(result["proposed_associations"].as_u64().unwrap() >= 1);
803    }
804
805    #[tokio::test]
806    async fn association_mine_with_min_strength() {
807        let (_tmp, store) = open_store();
808        let tool = AssociationMineTool::new(store.clone());
809
810        let m1 = make_memory(Galaxy::Codex, "rust fast systems", &["rust"]);
811        let m2 = make_memory(Galaxy::Codex, "python slow scripting", &["python"]);
812        store.put(Galaxy::Codex, &m1).unwrap();
813        store.put(Galaxy::Codex, &m2).unwrap();
814
815        let mut ctx = Context::new(wm_core::BrainWave::Beta);
816        let result = tool
817            .call(&mut ctx, json!({"min_strength": 0.9}))
818            .await
819            .unwrap();
820        assert_eq!(result["proposed_associations"], 0);
821    }
822
823    #[tokio::test]
824    async fn pattern_detect_empty_graph() {
825        let (_tmp, store) = open_store();
826        let tool = PatternDetectTool::new(store);
827        let mut ctx = Context::new(wm_core::BrainWave::Beta);
828        let result = tool.call(&mut ctx, json!({})).await.unwrap();
829        assert_eq!(result["status"], "success");
830        assert_eq!(result["total_nodes"], 0);
831    }
832
833    #[tokio::test]
834    async fn pattern_detect_finds_hubs() {
835        let (_tmp, store) = open_store();
836        let env = store.env();
837        let assoc_store = AssociationStore::open(env).unwrap();
838
839        let m1 = make_memory(Galaxy::Codex, "hub memory about rust", &["rust"]);
840        let m2 = make_memory(Galaxy::Codex, "spoke one about rust", &["rust"]);
841        let m3 = make_memory(Galaxy::Codex, "spoke two about rust", &["rust"]);
842        store.put(Galaxy::Codex, &m1).unwrap();
843        store.put(Galaxy::Codex, &m2).unwrap();
844        store.put(Galaxy::Codex, &m3).unwrap();
845
846        assoc_store
847            .put(
848                env,
849                &wm_memory::Association::new(
850                    m1.metadata.id,
851                    m2.metadata.id,
852                    wm_memory::LinkType::Related,
853                    0.8,
854                ),
855            )
856            .unwrap();
857        assoc_store
858            .put(
859                env,
860                &wm_memory::Association::new(
861                    m1.metadata.id,
862                    m3.metadata.id,
863                    wm_memory::LinkType::Related,
864                    0.7,
865                ),
866            )
867            .unwrap();
868
869        let tool = PatternDetectTool::new(store.clone());
870        let mut ctx = Context::new(wm_core::BrainWave::Beta);
871        let result = tool.call(&mut ctx, json!({})).await.unwrap();
872        assert_eq!(result["status"], "success");
873        assert!(result["total_nodes"].as_u64().unwrap() >= 3);
874        let hubs = result["hubs"].as_array().unwrap();
875        assert!(!hubs.is_empty());
876        let first_hub_degree = hubs[0]["total_degree"].as_u64().unwrap();
877        assert!(first_hub_degree >= 2);
878    }
879
880    #[tokio::test]
881    async fn emergence_report_basic() {
882        let (_tmp, store) = open_store();
883        let tool = EmergenceReportTool::new(store.clone());
884
885        store
886            .put(
887                Galaxy::Codex,
888                &make_memory(Galaxy::Codex, "rust fact", &["rust", "programming"]),
889            )
890            .unwrap();
891        store
892            .put(
893                Galaxy::Codex,
894                &make_memory(Galaxy::Codex, "python fact", &["python", "programming"]),
895            )
896            .unwrap();
897        store
898            .put(
899                Galaxy::Codex,
900                &make_memory(Galaxy::Codex, "rust again", &["rust"]),
901            )
902            .unwrap();
903
904        let mut ctx = Context::new(wm_core::BrainWave::Beta);
905        let result = tool.call(&mut ctx, json!({})).await.unwrap();
906        assert_eq!(result["status"], "success");
907        assert_eq!(result["total_memories"], 3);
908        assert!(result["unique_tags"].as_u64().unwrap() >= 2);
909    }
910
911    #[tokio::test]
912    async fn emergence_report_empty() {
913        let (_tmp, store) = open_store();
914        let tool = EmergenceReportTool::new(store);
915        let mut ctx = Context::new(wm_core::BrainWave::Beta);
916        let result = tool.call(&mut ctx, json!({})).await.unwrap();
917        assert_eq!(result["status"], "success");
918        assert_eq!(result["total_memories"], 0);
919    }
920
921    #[tokio::test]
922    async fn network_stats_empty() {
923        let (_tmp, store) = open_store();
924        let tool = NetworkStatsTool::new(store);
925        let mut ctx = Context::new(wm_core::BrainWave::Beta);
926        let result = tool.call(&mut ctx, json!({})).await.unwrap();
927        assert_eq!(result["status"], "success");
928        assert_eq!(result["nodes"], 0);
929        assert_eq!(result["edges"], 0);
930    }
931
932    #[tokio::test]
933    async fn network_stats_with_edges() {
934        let (_tmp, store) = open_store();
935        let env = store.env();
936        let assoc_store = AssociationStore::open(env).unwrap();
937
938        let m1 = make_memory(Galaxy::Codex, "memory one", &["test"]);
939        let m2 = make_memory(Galaxy::Codex, "memory two", &["test"]);
940        store.put(Galaxy::Codex, &m1).unwrap();
941        store.put(Galaxy::Codex, &m2).unwrap();
942
943        assoc_store
944            .put(
945                env,
946                &wm_memory::Association::new(
947                    m1.metadata.id,
948                    m2.metadata.id,
949                    wm_memory::LinkType::Related,
950                    0.5,
951                ),
952            )
953            .unwrap();
954
955        let tool = NetworkStatsTool::new(store.clone());
956        let mut ctx = Context::new(wm_core::BrainWave::Beta);
957        let result = tool.call(&mut ctx, json!({})).await.unwrap();
958        assert_eq!(result["status"], "success");
959        assert_eq!(result["edges"], 1);
960        assert!(result["nodes"].as_u64().unwrap() >= 2);
961    }
962
963    #[tokio::test]
964    async fn network_centrality_basic() {
965        let (_tmp, store) = open_store();
966        let env = store.env();
967        let assoc_store = AssociationStore::open(env).unwrap();
968
969        let m1 = make_memory(Galaxy::Codex, "hub memory", &["hub"]);
970        let m2 = make_memory(Galaxy::Codex, "spoke one", &["spoke"]);
971        let m3 = make_memory(Galaxy::Codex, "spoke two", &["spoke"]);
972        store.put(Galaxy::Codex, &m1).unwrap();
973        store.put(Galaxy::Codex, &m2).unwrap();
974        store.put(Galaxy::Codex, &m3).unwrap();
975
976        assoc_store
977            .put(
978                env,
979                &wm_memory::Association::new(
980                    m1.metadata.id,
981                    m2.metadata.id,
982                    wm_memory::LinkType::Related,
983                    0.8,
984                ),
985            )
986            .unwrap();
987        assoc_store
988            .put(
989                env,
990                &wm_memory::Association::new(
991                    m1.metadata.id,
992                    m3.metadata.id,
993                    wm_memory::LinkType::Related,
994                    0.7,
995                ),
996            )
997            .unwrap();
998
999        let tool = NetworkCentralityTool::new(store.clone());
1000        let mut ctx = Context::new(wm_core::BrainWave::Beta);
1001        let result = tool.call(&mut ctx, json!({"top_k": 5})).await.unwrap();
1002        assert_eq!(result["status"], "success");
1003        let nodes = result["top_nodes"].as_array().unwrap();
1004        assert!(!nodes.is_empty());
1005        let first_degree = nodes[0]["total_degree"].as_u64().unwrap();
1006        assert!(first_degree >= 2);
1007    }
1008
1009    #[tokio::test]
1010    async fn network_centrality_empty() {
1011        let (_tmp, store) = open_store();
1012        let tool = NetworkCentralityTool::new(store);
1013        let mut ctx = Context::new(wm_core::BrainWave::Beta);
1014        let result = tool.call(&mut ctx, json!({})).await.unwrap();
1015        assert_eq!(result["status"], "success");
1016        assert_eq!(result["total_nodes_with_edges"], 0);
1017    }
1018
1019    #[tokio::test]
1020    async fn network_clusters_empty() {
1021        let (_tmp, store) = open_store();
1022        let tool = NetworkClustersTool::new(store);
1023        let mut ctx = Context::new(wm_core::BrainWave::Beta);
1024        let result = tool.call(&mut ctx, json!({})).await.unwrap();
1025        assert_eq!(result["status"], "success");
1026        assert_eq!(result["total_nodes"], 0);
1027        assert_eq!(result["num_clusters"], 0);
1028    }
1029
1030    #[tokio::test]
1031    async fn network_clusters_connected() {
1032        let (_tmp, store) = open_store();
1033        let env = store.env();
1034        let assoc_store = AssociationStore::open(env).unwrap();
1035
1036        let m1 = make_memory(Galaxy::Codex, "node one", &["cluster"]);
1037        let m2 = make_memory(Galaxy::Codex, "node two", &["cluster"]);
1038        let m3 = make_memory(Galaxy::Codex, "node three", &["cluster"]);
1039        store.put(Galaxy::Codex, &m1).unwrap();
1040        store.put(Galaxy::Codex, &m2).unwrap();
1041        store.put(Galaxy::Codex, &m3).unwrap();
1042
1043        // m1 -> m2 -> m3 (all connected)
1044        assoc_store
1045            .put(
1046                env,
1047                &wm_memory::Association::new(
1048                    m1.metadata.id,
1049                    m2.metadata.id,
1050                    wm_memory::LinkType::Related,
1051                    0.5,
1052                ),
1053            )
1054            .unwrap();
1055        assoc_store
1056            .put(
1057                env,
1058                &wm_memory::Association::new(
1059                    m2.metadata.id,
1060                    m3.metadata.id,
1061                    wm_memory::LinkType::Related,
1062                    0.5,
1063                ),
1064            )
1065            .unwrap();
1066
1067        let tool = NetworkClustersTool::new(store.clone());
1068        let mut ctx = Context::new(wm_core::BrainWave::Beta);
1069        let result = tool.call(&mut ctx, json!({})).await.unwrap();
1070        assert_eq!(result["status"], "success");
1071        assert_eq!(result["num_clusters"], 1);
1072        assert_eq!(result["largest_cluster_size"], 3);
1073        assert_eq!(result["isolated_nodes"], 0);
1074    }
1075
1076    #[tokio::test]
1077    async fn network_clusters_isolated() {
1078        let (_tmp, store) = open_store();
1079        let env = store.env();
1080        let assoc_store = AssociationStore::open(env).unwrap();
1081
1082        let m1 = make_memory(Galaxy::Codex, "node one", &["solo"]);
1083        let m2 = make_memory(Galaxy::Codex, "node two", &["solo"]);
1084        store.put(Galaxy::Codex, &m1).unwrap();
1085        store.put(Galaxy::Codex, &m2).unwrap();
1086
1087        // Only one edge — m1 and m2 connected, no isolated nodes
1088        assoc_store
1089            .put(
1090                env,
1091                &wm_memory::Association::new(
1092                    m1.metadata.id,
1093                    m2.metadata.id,
1094                    wm_memory::LinkType::Related,
1095                    0.5,
1096                ),
1097            )
1098            .unwrap();
1099
1100        let tool = NetworkClustersTool::new(store.clone());
1101        let mut ctx = Context::new(wm_core::BrainWave::Beta);
1102        let result = tool.call(&mut ctx, json!({})).await.unwrap();
1103        assert_eq!(result["num_clusters"], 1);
1104        assert_eq!(result["isolated_nodes"], 0);
1105    }
1106}