Skip to main content

wm_tools/expansion/
archaeology.rs

1//! Archaeology & learning tools — archaeology.search, learning.pattern, learning.suggest.
2//!
3//! Gana::Ox — "Archaeology search, learning, pattern learning"
4//!
5//! These tools excavate memory layers for patterns, recurring themes,
6//! and learning suggestions based on access history and importance decay.
7
8#![forbid(unsafe_code)]
9
10use async_trait::async_trait;
11
12use serde_json::{Value, json};
13use std::collections::HashMap;
14use std::sync::Arc;
15use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
16use wm_memory::MemoryStore;
17
18use super::common::{galaxy_name, parse_galaxy};
19
20// ── archaeology.search ───────────────────────────────────────────────
21
22/// Excavate memories by time depth, importance layer, and content patterns.
23///
24/// Unlike `memory.search` (BM25 keyword match) or `pattern.search` (substring
25/// match), `archaeology.search` stratifies memories by age and importance,
26/// returning layers that reveal how knowledge evolved over time.
27pub struct ArchaeologySearchTool {
28    store: Arc<MemoryStore>,
29    stats: ToolStats,
30    effects: EffectRow,
31}
32
33impl ArchaeologySearchTool {
34    pub fn new(store: Arc<MemoryStore>) -> Self {
35        Self {
36            store,
37            stats: ToolStats::default(),
38            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
39        }
40    }
41}
42
43#[async_trait]
44impl Tool for ArchaeologySearchTool {
45    fn name(&self) -> &str {
46        "archaeology.search"
47    }
48    fn gana(&self) -> Gana {
49        Gana::Ox
50    }
51    fn effects(&self) -> &EffectRow {
52        &self.effects
53    }
54    fn description(&self) -> &str {
55        "Excavate memory layers by time depth and importance stratification"
56    }
57    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
58        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
59        let keyword = args.get("keyword").and_then(|v| v.as_str()).unwrap_or("");
60        let max_layers = args
61            .get("max_layers")
62            .and_then(serde_json::Value::as_u64)
63            .unwrap_or(5) as usize;
64        let memories_per_layer = args
65            .get("memories_per_layer")
66            .and_then(serde_json::Value::as_u64)
67            .unwrap_or(5) as usize;
68
69        let galaxies: Vec<Galaxy> = match galaxy_str {
70            Some(g) => vec![parse_galaxy(g)?],
71            None => Galaxy::memory_galaxies().to_vec(),
72        };
73
74        // Collect all matching memories
75        let mut all_mems: Vec<(Galaxy, wm_memory::Memory)> = Vec::new();
76        for galaxy in &galaxies {
77            let mems = self.store.scan(*galaxy, 1000)?;
78            for mem in mems {
79                if keyword.is_empty()
80                    || mem.content.to_lowercase().contains(&keyword.to_lowercase())
81                {
82                    all_mems.push((*galaxy, mem));
83                }
84            }
85        }
86
87        if all_mems.is_empty() {
88            return Ok(json!({
89                "status": "success",
90                "total_memories": 0,
91                "layers": [],
92            }));
93        }
94
95        // Sort by created_at descending (newest first)
96        all_mems.sort_by_key(|x| std::cmp::Reverse(x.1.metadata.created_at));
97
98        // Stratify into time layers
99        let total = all_mems.len();
100        let layer_size = (total / max_layers).max(1);
101        let mut layers: Vec<Value> = Vec::new();
102
103        for (layer_idx, chunk) in all_mems.chunks(layer_size).enumerate().take(max_layers) {
104            let layer_mems: Vec<Value> = chunk
105                .iter()
106                .take(memories_per_layer)
107                .map(|(galaxy, mem)| {
108                    json!({
109                        "galaxy": galaxy_name(*galaxy),
110                        "id": mem.metadata.id,
111                        "content_preview": mem.content.chars().take(120).collect::<String>(),
112                        "importance": mem.metadata.importance,
113                        "created_at": mem.metadata.created_at.to_rfc3339(),
114                        "tags": mem.metadata.tags,
115                    })
116                })
117                .collect();
118
119            let avg_importance: f32 = if chunk.is_empty() {
120                0.0
121            } else {
122                chunk
123                    .iter()
124                    .map(|(_, m)| m.metadata.importance)
125                    .sum::<f32>()
126                    / chunk.len() as f32
127            };
128
129            layers.push(json!({
130                "layer": layer_idx,
131                "depth": if layer_idx == 0 { "newest" } else if layer_idx == max_layers - 1 { "oldest" } else { "middle" },
132                "count": chunk.len(),
133                "avg_importance": (avg_importance * 100.0).round() / 100.0,
134                "memories": layer_mems,
135            }));
136        }
137
138        Ok(json!({
139            "status": "success",
140            "total_memories": total,
141            "galaxies_searched": galaxies.len(),
142            "keyword": keyword,
143            "layers": layers,
144        }))
145    }
146    fn stats(&self) -> &ToolStats {
147        &self.stats
148    }
149}
150
151// ── learning.pattern ─────────────────────────────────────────────────
152
153/// Detect recurring patterns in memory content across galaxies.
154///
155/// Analyzes tag co-occurrence and content keyword frequency to identify
156/// recurring themes and learning patterns. Returns ranked patterns with
157/// supporting memory references.
158pub struct LearningPatternTool {
159    store: Arc<MemoryStore>,
160    stats: ToolStats,
161    effects: EffectRow,
162}
163
164impl LearningPatternTool {
165    pub fn new(store: Arc<MemoryStore>) -> Self {
166        Self {
167            store,
168            stats: ToolStats::default(),
169            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
170        }
171    }
172}
173
174#[async_trait]
175impl Tool for LearningPatternTool {
176    fn name(&self) -> &str {
177        "learning.pattern"
178    }
179    fn gana(&self) -> Gana {
180        Gana::Ox
181    }
182    fn effects(&self) -> &EffectRow {
183        &self.effects
184    }
185    fn description(&self) -> &str {
186        "Detect recurring patterns and themes across memory galaxies"
187    }
188    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
189        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
190        let min_frequency = args
191            .get("min_frequency")
192            .and_then(serde_json::Value::as_u64)
193            .unwrap_or(2) as usize;
194        let top_n = args
195            .get("top_n")
196            .and_then(serde_json::Value::as_u64)
197            .unwrap_or(10) as usize;
198        let scan_limit = args
199            .get("scan_limit")
200            .and_then(serde_json::Value::as_u64)
201            .unwrap_or(500) as usize;
202
203        let galaxies: Vec<Galaxy> = match galaxy_str {
204            Some(g) => vec![parse_galaxy(g)?],
205            None => Galaxy::memory_galaxies().to_vec(),
206        };
207
208        // Track tag co-occurrence and keyword frequency
209        let mut tag_pairs: HashMap<(String, String), u32> = HashMap::new();
210        let mut keyword_freq: HashMap<String, u32> = HashMap::new();
211        let mut total_memories = 0usize;
212
213        for galaxy in &galaxies {
214            let mems = self.store.scan(*galaxy, scan_limit)?;
215            for mem in &mems {
216                total_memories += 1;
217                // Tag co-occurrence
218                let tags = &mem.metadata.tags;
219                for i in 0..tags.len() {
220                    for j in (i + 1)..tags.len() {
221                        let pair = if tags[i] < tags[j] {
222                            (tags[i].clone(), tags[j].clone())
223                        } else {
224                            (tags[j].clone(), tags[i].clone())
225                        };
226                        *tag_pairs.entry(pair).or_default() += 1;
227                    }
228                }
229                // Keyword frequency (simple word tokenization)
230                for word in mem.content.split_whitespace() {
231                    let w = word
232                        .trim_matches(|c: char| !c.is_alphanumeric())
233                        .to_lowercase();
234                    if w.len() > 3 {
235                        *keyword_freq.entry(w).or_default() += 1;
236                    }
237                }
238            }
239        }
240
241        // Filter and rank tag co-occurrences
242        let mut tag_patterns: Vec<Value> = tag_pairs
243            .iter()
244            .filter(|&(_, count)| *count >= min_frequency as u32)
245            .map(|((t1, t2), count)| {
246                json!({
247                    "tags": [t1, t2],
248                    "co_occurrence": count,
249                })
250            })
251            .collect();
252        tag_patterns.sort_by(|a, b| {
253            b["co_occurrence"]
254                .as_u64()
255                .cmp(&a["co_occurrence"].as_u64())
256        });
257        tag_patterns.truncate(top_n);
258
259        // Filter and rank keywords
260        let mut keywords: Vec<(String, u32)> = keyword_freq
261            .into_iter()
262            .filter(|(_, count)| *count >= min_frequency as u32)
263            .collect();
264        keywords.sort_by_key(|x| std::cmp::Reverse(x.1));
265        keywords.truncate(top_n);
266
267        let keyword_patterns: Vec<Value> = keywords
268            .into_iter()
269            .map(|(word, count)| {
270                json!({
271                    "keyword": word,
272                    "frequency": count,
273                })
274            })
275            .collect();
276
277        Ok(json!({
278            "status": "success",
279            "total_memories": total_memories,
280            "galaxies_scanned": galaxies.len(),
281            "tag_patterns": tag_patterns,
282            "keyword_patterns": keyword_patterns,
283            "min_frequency": min_frequency,
284        }))
285    }
286    fn stats(&self) -> &ToolStats {
287        &self.stats
288    }
289}
290
291// ── learning.suggest ─────────────────────────────────────────────────
292
293/// Suggest learning paths based on memory gaps and importance clusters.
294///
295/// Analyzes which topics have high importance but low memory count (gaps)
296/// and which have high memory count but low importance (saturation).
297/// Returns suggestions for what to learn next.
298pub struct LearningSuggestTool {
299    store: Arc<MemoryStore>,
300    stats: ToolStats,
301    effects: EffectRow,
302}
303
304impl LearningSuggestTool {
305    pub fn new(store: Arc<MemoryStore>) -> Self {
306        Self {
307            store,
308            stats: ToolStats::default(),
309            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
310        }
311    }
312}
313
314#[async_trait]
315impl Tool for LearningSuggestTool {
316    fn name(&self) -> &str {
317        "learning.suggest"
318    }
319    fn gana(&self) -> Gana {
320        Gana::Ox
321    }
322    fn effects(&self) -> &EffectRow {
323        &self.effects
324    }
325    fn description(&self) -> &str {
326        "Suggest learning paths based on memory gaps and importance clusters"
327    }
328    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
329        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
330        let top_n = args
331            .get("top_n")
332            .and_then(serde_json::Value::as_u64)
333            .unwrap_or(10) as usize;
334
335        let galaxies: Vec<Galaxy> = match galaxy_str {
336            Some(g) => vec![parse_galaxy(g)?],
337            None => vec![Galaxy::Codex, Galaxy::Research, Galaxy::Tutorial],
338        };
339
340        // Aggregate by tag: count, avg importance, total access
341        let mut tag_stats: HashMap<String, (u32, f32, u64)> = HashMap::new(); // (count, avg_importance, total_access)
342
343        for galaxy in &galaxies {
344            let mems = self.store.scan(*galaxy, 1000)?;
345            for mem in &mems {
346                for tag in &mem.metadata.tags {
347                    let entry = tag_stats.entry(tag.clone()).or_insert((0, 0.0, 0));
348                    entry.0 += 1;
349                    entry.1 += mem.metadata.importance;
350                    entry.2 += mem.metadata.access_count;
351                }
352            }
353        }
354
355        // Finalize averages
356        let mut tag_data: Vec<(String, u32, f32, u64)> = tag_stats
357            .into_iter()
358            .map(|(tag, (count, imp_sum, access))| (tag, count, imp_sum / count as f32, access))
359            .collect();
360
361        // Gaps: high importance, low count → worth exploring
362        let mut gaps: Vec<Value> = tag_data
363            .iter()
364            .filter(|(_, count, imp, _)| *count <= 3 && *imp > 0.5)
365            .map(|(tag, count, imp, access)| {
366                json!({
367                    "tag": tag,
368                    "memory_count": count,
369                    "avg_importance": (imp * 100.0).round() / 100.0,
370                    "total_access": access,
371                    "suggestion": format!("Topic '{}' has high importance but few memories — consider exploring further", tag),
372                })
373            })
374            .collect();
375        gaps.sort_by(|a, b| {
376            b["avg_importance"]
377                .as_f64()
378                .partial_cmp(&a["avg_importance"].as_f64())
379                .unwrap_or(std::cmp::Ordering::Equal)
380        });
381        gaps.truncate(top_n);
382
383        // Saturated: high count, low importance → already well-covered
384        let mut saturated: Vec<Value> = tag_data
385            .iter()
386            .filter(|(_, count, imp, _)| *count >= 10 && *imp < 0.4)
387            .map(|(tag, count, imp, access)| {
388                json!({
389                    "tag": tag,
390                    "memory_count": count,
391                    "avg_importance": (imp * 100.0).round() / 100.0,
392                    "total_access": access,
393                    "suggestion": format!("Topic '{}' is well-covered with {} memories — consider synthesizing or consolidating", tag, count),
394                })
395            })
396            .collect();
397        saturated.sort_by(|a, b| b["memory_count"].as_u64().cmp(&a["memory_count"].as_u64()));
398        saturated.truncate(top_n);
399
400        // Hot topics: high count + high importance
401        let mut hot: Vec<Value> = tag_data
402            .iter()
403            .filter(|(_, count, imp, _)| *count >= 5 && *imp > 0.6)
404            .map(|(tag, count, imp, access)| {
405                json!({
406                    "tag": tag,
407                    "memory_count": count,
408                    "avg_importance": (imp * 100.0).round() / 100.0,
409                    "total_access": access,
410                })
411            })
412            .collect();
413        hot.sort_by(|a, b| {
414            b["avg_importance"]
415                .as_f64()
416                .partial_cmp(&a["avg_importance"].as_f64())
417                .unwrap_or(std::cmp::Ordering::Equal)
418        });
419        hot.truncate(top_n);
420
421        // Clean up tag_data to avoid clippy warning
422        tag_data.clear();
423
424        Ok(json!({
425            "status": "success",
426            "galaxies_scanned": galaxies.len(),
427            "gaps": gaps,
428            "saturated": saturated,
429            "hot_topics": hot,
430            "suggestion_count": gaps.len() + saturated.len(),
431        }))
432    }
433    fn stats(&self) -> &ToolStats {
434        &self.stats
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
443        let tmp = tempfile::tempdir().unwrap();
444        let store = MemoryStore::open_default(tmp.path()).unwrap();
445        (tmp, Arc::new(store))
446    }
447
448    fn seed_memories(store: &Arc<MemoryStore>) {
449        let galaxies = [Galaxy::Codex, Galaxy::Research, Galaxy::Tutorial];
450        let contents = [
451            "Rust programming language features and memory safety",
452            "Rust ownership model and borrow checker rules",
453            "Python async programming with asyncio library",
454            "Machine learning fundamentals and neural networks",
455            "Quantum computing principles and qubit operations",
456            "Database design patterns for scalable systems",
457            "Rust trait system and generic constraints",
458            "Python data science with pandas and numpy",
459        ];
460        let tags_sets: Vec<Vec<String>> = vec![
461            vec!["rust".into(), "programming".into()],
462            vec!["rust".into(), "programming".into(), "memory".into()],
463            vec!["python".into(), "programming".into(), "async".into()],
464            vec!["ml".into(), "neural".into()],
465            vec!["quantum".into(), "physics".into()],
466            vec!["database".into(), "design".into()],
467            vec!["rust".into(), "programming".into(), "traits".into()],
468            vec!["python".into(), "data".into()],
469        ];
470        let importances = [0.9, 0.8, 0.5, 0.7, 0.6, 0.4, 0.85, 0.3];
471
472        for i in 0..8 {
473            let galaxy = galaxies[i % 3];
474            let mut mem = wm_memory::Memory::new(galaxy, contents[i].into());
475            mem.metadata.tags = tags_sets[i].clone();
476            mem.metadata.importance = importances[i];
477            store.put(galaxy, &mem).unwrap();
478        }
479    }
480
481    #[tokio::test]
482    async fn archaeology_search_returns_layers() {
483        let (_tmp, store) = open_store();
484        seed_memories(&store);
485
486        let tool = ArchaeologySearchTool::new(store);
487        let result = tool
488            .call(&mut Context::default(), json!({"max_layers": 3}))
489            .await
490            .unwrap();
491        let obj = result.as_object().unwrap();
492        assert_eq!(obj["status"], "success");
493        assert!(obj["total_memories"].as_u64().unwrap() >= 8);
494        let layers = obj["layers"].as_array().unwrap();
495        assert!(!layers.is_empty());
496        assert!(layers.len() <= 3);
497    }
498
499    #[tokio::test]
500    async fn archaeology_search_with_keyword() {
501        let (_tmp, store) = open_store();
502        seed_memories(&store);
503
504        let tool = ArchaeologySearchTool::new(store);
505        let result = tool
506            .call(
507                &mut Context::default(),
508                json!({"keyword": "rust", "max_layers": 2}),
509            )
510            .await
511            .unwrap();
512        let obj = result.as_object().unwrap();
513        assert_eq!(obj["status"], "success");
514        assert!(obj["total_memories"].as_u64().unwrap() >= 3);
515    }
516
517    #[tokio::test]
518    async fn archaeology_search_empty_galaxy() {
519        let (_tmp, store) = open_store();
520        let tool = ArchaeologySearchTool::new(store);
521        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
522        let obj = result.as_object().unwrap();
523        assert_eq!(obj["status"], "success");
524        assert_eq!(obj["total_memories"], 0);
525    }
526
527    #[tokio::test]
528    async fn learning_pattern_detects_tag_co_occurrence() {
529        let (_tmp, store) = open_store();
530        seed_memories(&store);
531
532        let tool = LearningPatternTool::new(store);
533        let result = tool
534            .call(
535                &mut Context::default(),
536                json!({"min_frequency": 2, "top_n": 10}),
537            )
538            .await
539            .unwrap();
540        let obj = result.as_object().unwrap();
541        assert_eq!(obj["status"], "success");
542        assert!(obj["total_memories"].as_u64().unwrap() >= 8);
543        // "rust" appears in 3 memories, "python" in 2
544        let tag_patterns = obj["tag_patterns"].as_array().unwrap();
545        assert!(!tag_patterns.is_empty());
546    }
547
548    #[tokio::test]
549    async fn learning_pattern_detects_keywords() {
550        let (_tmp, store) = open_store();
551        seed_memories(&store);
552
553        let tool = LearningPatternTool::new(store);
554        let result = tool
555            .call(
556                &mut Context::default(),
557                json!({"min_frequency": 2, "top_n": 5}),
558            )
559            .await
560            .unwrap();
561        let obj = result.as_object().unwrap();
562        let keywords = obj["keyword_patterns"].as_array().unwrap();
563        assert!(!keywords.is_empty());
564        // "rust" should appear as a frequent keyword
565        let has_rust = keywords
566            .iter()
567            .any(|k| k["keyword"].as_str().unwrap_or("") == "rust");
568        assert!(has_rust, "Expected 'rust' in keyword patterns");
569    }
570
571    #[tokio::test]
572    async fn learning_suggest_finds_gaps_and_hot_topics() {
573        let (_tmp, store) = open_store();
574        seed_memories(&store);
575
576        let tool = LearningSuggestTool::new(store);
577        let result = tool
578            .call(&mut Context::default(), json!({"top_n": 5}))
579            .await
580            .unwrap();
581        let obj = result.as_object().unwrap();
582        assert_eq!(obj["status"], "success");
583        // Should have some suggestions
584        let gaps = obj["gaps"].as_array().unwrap();
585        let hot = obj["hot_topics"].as_array().unwrap();
586        // "rust" has 3 memories with high importance → should be in hot or gaps
587        assert!(!gaps.is_empty() || !hot.is_empty());
588    }
589
590    #[tokio::test]
591    async fn learning_suggest_empty_store() {
592        let (_tmp, store) = open_store();
593        let tool = LearningSuggestTool::new(store);
594        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
595        let obj = result.as_object().unwrap();
596        assert_eq!(obj["status"], "success");
597        assert_eq!(obj["gaps"].as_array().unwrap().len(), 0);
598        assert_eq!(obj["hot_topics"].as_array().unwrap().len(), 0);
599    }
600}