Skip to main content

wm_tools/expansion/
patterns.rs

1//! Pattern tools — pattern.search, salience.spotlight, serendipity.surface.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
10use wm_memory::{AssociationStore, MemoryStore};
11
12use super::common::{galaxy_name, parse_galaxy};
13
14pub struct PatternSearchTool {
15    store: Arc<MemoryStore>,
16    stats: ToolStats,
17    effects: EffectRow,
18}
19
20impl PatternSearchTool {
21    pub fn new(store: Arc<MemoryStore>) -> Self {
22        Self {
23            store,
24            stats: ToolStats::default(),
25            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
26        }
27    }
28}
29
30#[async_trait]
31impl Tool for PatternSearchTool {
32    fn name(&self) -> &str {
33        "pattern.search"
34    }
35    fn gana(&self) -> Gana {
36        Gana::Ox
37    }
38    fn effects(&self) -> &EffectRow {
39        &self.effects
40    }
41    fn description(&self) -> &str {
42        "Search for patterns in memory content across galaxies"
43    }
44    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
45        let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
46        let galaxies = args.get("galaxies").and_then(|v| v.as_array());
47        let limit = args
48            .get("limit")
49            .and_then(serde_json::Value::as_u64)
50            .unwrap_or(20) as usize;
51        let galaxies_to_search: Vec<Galaxy> = match galaxies {
52            Some(arr) => {
53                let mut parsed = Vec::new();
54                for g in arr {
55                    if let Some(name) = g.as_str() {
56                        parsed.push(parse_galaxy(name)?);
57                    }
58                }
59                parsed
60            }
61            None => Galaxy::memory_galaxies().to_vec(),
62        };
63        let mut matches = Vec::new();
64        for galaxy in &galaxies_to_search {
65            let memories = self.store.scan(*galaxy, 500)?;
66            for mem in memories {
67                if mem.content.to_lowercase().contains(&pattern.to_lowercase()) {
68                    matches.push(json!({
69                        "galaxy": galaxy_name(*galaxy),
70                        "id": mem.metadata.id,
71                        "content_preview": mem.content.chars().take(100).collect::<String>(),
72                    }));
73                    if matches.len() >= limit {
74                        break;
75                    }
76                }
77            }
78            if matches.len() >= limit {
79                break;
80            }
81        }
82        Ok(json!({
83            "status": "success",
84            "pattern": pattern,
85            "matches": matches.len(),
86            "results": matches,
87        }))
88    }
89    fn stats(&self) -> &ToolStats {
90        &self.stats
91    }
92}
93
94/// `salience.spotlight` — find high-importance memories across galaxies.
95pub struct SalienceSpotlightTool {
96    store: Arc<MemoryStore>,
97    stats: ToolStats,
98    effects: EffectRow,
99}
100
101impl SalienceSpotlightTool {
102    pub fn new(store: Arc<MemoryStore>) -> Self {
103        Self {
104            store,
105            stats: ToolStats::default(),
106            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
107        }
108    }
109}
110
111#[async_trait]
112impl Tool for SalienceSpotlightTool {
113    fn name(&self) -> &str {
114        "salience.spotlight"
115    }
116    fn gana(&self) -> Gana {
117        Gana::Ox
118    }
119    fn effects(&self) -> &EffectRow {
120        &self.effects
121    }
122    fn description(&self) -> &str {
123        "Find high-importance memories across all galaxies"
124    }
125    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
126        let min_importance = args
127            .get("min_importance")
128            .and_then(serde_json::Value::as_f64)
129            .unwrap_or(0.8) as f32;
130        let limit = args
131            .get("limit")
132            .and_then(serde_json::Value::as_u64)
133            .unwrap_or(20) as usize;
134        let mut spotlighted = Vec::new();
135        for galaxy in Galaxy::memory_galaxies() {
136            let memories = self.store.scan(galaxy, 200)?;
137            for mem in memories {
138                if mem.metadata.importance >= min_importance {
139                    spotlighted.push(json!({
140                        "galaxy": galaxy_name(galaxy),
141                        "id": mem.metadata.id,
142                        "importance": mem.metadata.importance,
143                        "content_preview": mem.content.chars().take(80).collect::<String>(),
144                    }));
145                }
146            }
147        }
148        spotlighted.sort_by(|a, b| {
149            b["importance"]
150                .as_f64()
151                .unwrap_or(0.0)
152                .partial_cmp(&a["importance"].as_f64().unwrap_or(0.0))
153                .unwrap_or(std::cmp::Ordering::Equal)
154        });
155        spotlighted.truncate(limit);
156        Ok(json!({
157            "status": "success",
158            "min_importance": min_importance,
159            "count": spotlighted.len(),
160            "spotlight": spotlighted,
161        }))
162    }
163    fn stats(&self) -> &ToolStats {
164        &self.stats
165    }
166}
167
168/// `serendipity.surface` — surface unexpected cross-galaxy connections.
169pub struct SerendipitySurfaceTool {
170    store: Arc<MemoryStore>,
171    stats: ToolStats,
172    effects: EffectRow,
173}
174
175impl SerendipitySurfaceTool {
176    pub fn new(store: Arc<MemoryStore>) -> Self {
177        Self {
178            store,
179            stats: ToolStats::default(),
180            effects: EffectRow::read_only(vec![Resource::Galaxy("associations".into())]),
181        }
182    }
183}
184
185#[async_trait]
186impl Tool for SerendipitySurfaceTool {
187    fn name(&self) -> &str {
188        "serendipity.surface"
189    }
190    fn gana(&self) -> Gana {
191        Gana::Star
192    }
193    fn effects(&self) -> &EffectRow {
194        &self.effects
195    }
196    fn description(&self) -> &str {
197        "Surface unexpected cross-galaxy connections from associations"
198    }
199    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
200        let env = self.store.env();
201        let assoc_store = AssociationStore::open(env)?;
202        let total = assoc_store.count(env)?;
203
204        // Sample memories from each galaxy to find cross-galaxy associations
205        let mut cross_galaxy: Vec<Value> = Vec::new();
206        for galaxy in Galaxy::memory_galaxies() {
207            let memories = self.store.scan(galaxy, 50)?;
208            for mem in &memories {
209                let assocs = assoc_store.find_from(env, mem.metadata.id)?;
210                for assoc in &assocs {
211                    // Check if target is in a different galaxy
212                    for other_galaxy in Galaxy::memory_galaxies() {
213                        if other_galaxy != galaxy {
214                            if let Ok(Some(_)) = self.store.get(other_galaxy, assoc.target) {
215                                cross_galaxy.push(json!({
216                                    "source_galaxy": galaxy_name(galaxy),
217                                    "target_galaxy": galaxy_name(other_galaxy),
218                                    "weight": assoc.weight,
219                                    "link_type": assoc.link_type.as_str(),
220                                    "association_type": assoc.association_type,
221                                }));
222                            }
223                        }
224                    }
225                }
226            }
227        }
228
229        Ok(json!({
230            "status": "success",
231            "total_associations": total,
232            "cross_galaxy_links": cross_galaxy.len(),
233            "serendipities": cross_galaxy.into_iter().take(20).collect::<Vec<_>>(),
234        }))
235    }
236    fn stats(&self) -> &ToolStats {
237        &self.stats
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use wm_memory::{Association, LinkType, Memory};
245
246    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
247        let tmp = tempfile::tempdir().unwrap();
248        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
249        (tmp, store)
250    }
251
252    #[tokio::test]
253    async fn pattern_search_is_case_insensitive_and_galaxy_scoped() {
254        let (_tmp, store) = open_store();
255        let mut codex = Memory::new(Galaxy::Codex, "Needle in the codex haystack".into());
256        codex.metadata.id = uuid::Uuid::from_u128(0x701);
257        store.put(Galaxy::Codex, &codex).unwrap();
258        let mut research = Memory::new(Galaxy::Research, "needle elsewhere entirely".into());
259        research.metadata.id = uuid::Uuid::from_u128(0x702);
260        store.put(Galaxy::Research, &research).unwrap();
261
262        let scoped = PatternSearchTool::new(store.clone())
263            .call(
264                &mut Context::default(),
265                json!({"pattern": "NEEDLE", "galaxies": ["codex"]}),
266            )
267            .await
268            .unwrap();
269        assert_eq!(scoped["matches"], 1);
270        assert_eq!(
271            scoped["results"][0]["id"].as_str().unwrap(),
272            codex.metadata.id.to_string()
273        );
274
275        let absent = PatternSearchTool::new(store)
276            .call(&mut Context::default(), json!({"pattern": "zzz-absent"}))
277            .await
278            .unwrap();
279        assert_eq!(absent["matches"], 0);
280    }
281
282    #[tokio::test]
283    async fn salience_spotlight_filters_then_sorts_by_importance() {
284        let (_tmp, store) = open_store();
285        for (id, importance) in [(0x711u128, 0.9f32), (0x712, 0.85), (0x713, 0.2)] {
286            let mut memory = Memory::new(Galaxy::Codex, format!("importance fixture {id}"));
287            memory.metadata.id = uuid::Uuid::from_u128(id);
288            memory.metadata.importance = importance;
289            store.put(Galaxy::Codex, &memory).unwrap();
290        }
291
292        let result = SalienceSpotlightTool::new(store)
293            .call(
294                &mut Context::default(),
295                json!({"min_importance": 0.8, "limit": 1}),
296            )
297            .await
298            .unwrap();
299        assert_eq!(result["count"], 1);
300        assert_eq!(
301            result["spotlight"][0]["id"].as_str().unwrap(),
302            uuid::Uuid::from_u128(0x711).to_string()
303        );
304        assert!((result["spotlight"][0]["importance"].as_f64().unwrap() - 0.9).abs() < 1e-6);
305    }
306
307    #[tokio::test]
308    async fn serendipity_surfaces_only_cross_galaxy_links() {
309        let (_tmp, store) = open_store();
310        let mut source = Memory::new(Galaxy::Codex, "origin".into());
311        source.metadata.id = uuid::Uuid::from_u128(0x721);
312        let mut cross = Memory::new(Galaxy::Research, "cross galaxy target".into());
313        cross.metadata.id = uuid::Uuid::from_u128(0x722);
314        let mut same = Memory::new(Galaxy::Codex, "same galaxy target".into());
315        same.metadata.id = uuid::Uuid::from_u128(0x723);
316        store.put(Galaxy::Codex, &source).unwrap();
317        store.put(Galaxy::Research, &cross).unwrap();
318        store.put(Galaxy::Codex, &same).unwrap();
319
320        let env = store.env();
321        let assoc_store = AssociationStore::open(env).unwrap();
322        assoc_store
323            .put(
324                env,
325                &Association::new(
326                    source.metadata.id,
327                    cross.metadata.id,
328                    LinkType::Related,
329                    0.9,
330                ),
331            )
332            .unwrap();
333        assoc_store
334            .put(
335                env,
336                &Association::new(source.metadata.id, same.metadata.id, LinkType::Related, 0.5),
337            )
338            .unwrap();
339
340        let result = SerendipitySurfaceTool::new(store)
341            .call(&mut Context::default(), json!({}))
342            .await
343            .unwrap();
344        assert_eq!(result["total_associations"], 2);
345        assert_eq!(result["cross_galaxy_links"], 1);
346        assert_eq!(result["serendipities"][0]["source_galaxy"], "codex");
347        assert_eq!(result["serendipities"][0]["target_galaxy"], "research");
348    }
349}