Skip to main content

wm_tools/expansion/
boundary.rs

1//! Anti-loop & boundary tools — anti_loop.check, boundary.enforce.
2//!
3//! Gana::Wall — "Loop prevention, boundary enforcement, safety checks"
4//!
5//! `anti_loop.check` detects repetitive patterns in tool calls or memory
6//! creation that may indicate infinite loops or stuck states.
7//! `boundary.enforce` validates that operations stay within defined
8//! resource limits (memory count, galaxy size, tag sprawl).
9
10#![forbid(unsafe_code)]
11
12use async_trait::async_trait;
13
14use serde_json::{Value, json};
15use std::collections::HashMap;
16use std::sync::Arc;
17use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
18use wm_memory::MemoryStore;
19
20use super::common::galaxy_name;
21
22// ── anti_loop.check ──────────────────────────────────────────────────
23
24/// Detect repetitive patterns that may indicate loops or stuck states.
25///
26/// Analyzes recent memories for:
27/// - Duplicate content (exact or near-duplicate)
28/// - Repetitive tag patterns
29/// - Burst creation patterns (many memories in short time)
30/// - Content similarity clusters
31pub struct AntiLoopCheckTool {
32    store: Arc<MemoryStore>,
33    stats: ToolStats,
34    effects: EffectRow,
35}
36
37impl AntiLoopCheckTool {
38    pub fn new(store: Arc<MemoryStore>) -> Self {
39        Self {
40            store,
41            stats: ToolStats::default(),
42            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
43        }
44    }
45}
46
47#[async_trait]
48impl Tool for AntiLoopCheckTool {
49    fn name(&self) -> &str {
50        "anti_loop.check"
51    }
52    fn gana(&self) -> Gana {
53        Gana::Wall
54    }
55    fn effects(&self) -> &EffectRow {
56        &self.effects
57    }
58    fn description(&self) -> &str {
59        "Detect repetitive patterns indicating loops or stuck states"
60    }
61    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
62        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
63        let scan_limit = args
64            .get("scan_limit")
65            .and_then(serde_json::Value::as_u64)
66            .unwrap_or(200) as usize;
67        let similarity_threshold = args
68            .get("similarity_threshold")
69            .and_then(serde_json::Value::as_f64)
70            .unwrap_or(0.8) as f32;
71
72        let galaxies: Vec<Galaxy> = match galaxy_str {
73            Some(g) => vec![super::common::parse_galaxy(g)?],
74            None => Galaxy::memory_galaxies().to_vec(),
75        };
76
77        let mut all_mems: Vec<(Galaxy, wm_memory::Memory)> = Vec::new();
78        for galaxy in &galaxies {
79            let mems = self.store.scan(*galaxy, scan_limit)?;
80            all_mems.extend(mems.into_iter().map(|m| (*galaxy, m)));
81        }
82
83        if all_mems.is_empty() {
84            return Ok(json!({
85                "status": "success",
86                "total_memories": 0,
87                "loop_detected": false,
88                "warnings": [],
89            }));
90        }
91
92        // Sort by created_at descending (most recent first)
93        all_mems.sort_by_key(|x| std::cmp::Reverse(x.1.metadata.created_at));
94
95        let mut warnings: Vec<Value> = Vec::new();
96
97        // 1. Check for exact duplicates
98        let mut content_map: HashMap<String, u32> = HashMap::new();
99        for (_, mem) in &all_mems {
100            *content_map.entry(mem.content.clone()).or_default() += 1;
101        }
102        for (content, count) in &content_map {
103            if *count > 1 {
104                warnings.push(json!({
105                    "type": "exact_duplicate",
106                    "content_preview": content.chars().take(80).collect::<String>(),
107                    "count": count,
108                    "severity": if *count > 3 { "high" } else { "medium" },
109                }));
110            }
111        }
112
113        // 2. Check for near-duplicates (content prefix similarity)
114        let recent: Vec<&(Galaxy, wm_memory::Memory)> = all_mems.iter().take(50).collect();
115        for i in 0..recent.len() {
116            for j in (i + 1)..recent.len().min(i + 10) {
117                let a = &recent[i].1.content;
118                let b = &recent[j].1.content;
119                let similarity = content_similarity(a, b);
120                if similarity > similarity_threshold && a != b {
121                    warnings.push(json!({
122                        "type": "near_duplicate",
123                        "similarity": (similarity * 100.0).round() / 100.0,
124                        "memory_a": recent[i].1.metadata.id,
125                        "memory_b": recent[j].1.metadata.id,
126                        "content_preview": a.chars().take(60).collect::<String>(),
127                        "severity": "medium",
128                    }));
129                }
130            }
131        }
132
133        // 3. Check for burst creation (many memories within short time)
134        if all_mems.len() >= 10 {
135            let recent_10 = &all_mems[..10];
136            if let (Some(first), Some(last)) = (recent_10.last(), recent_10.first()) {
137                let duration = last.1.metadata.created_at - first.1.metadata.created_at;
138                if duration.num_seconds() < 60 {
139                    warnings.push(json!({
140                        "type": "burst_creation",
141                        "memories_in_burst": 10,
142                        "duration_seconds": duration.num_seconds(),
143                        "severity": "high",
144                        "message": "10+ memories created within 60 seconds — possible loop",
145                    }));
146                }
147            }
148        }
149
150        // 4. Check for repetitive tag patterns
151        let mut tag_pattern_count: HashMap<Vec<String>, u32> = HashMap::new();
152        for (_, mem) in &all_mems {
153            let mut tags = mem.metadata.tags.clone();
154            tags.sort();
155            *tag_pattern_count.entry(tags).or_default() += 1;
156        }
157        for (tags, count) in &tag_pattern_count {
158            if *count > 5 {
159                warnings.push(json!({
160                    "type": "repetitive_tags",
161                    "tags": tags,
162                    "count": count,
163                    "severity": if *count > 10 { "high" } else { "medium" },
164                }));
165            }
166        }
167
168        let loop_detected = warnings
169            .iter()
170            .any(|w| w["severity"].as_str().is_some_and(|s| s == "high"));
171
172        Ok(json!({
173            "status": "success",
174            "total_memories": all_mems.len(),
175            "galaxies_checked": galaxies.len(),
176            "loop_detected": loop_detected,
177            "warning_count": warnings.len(),
178            "warnings": warnings,
179        }))
180    }
181    fn stats(&self) -> &ToolStats {
182        &self.stats
183    }
184}
185
186/// Compute simple content similarity based on character-level Jaccard on n-grams.
187fn content_similarity(a: &str, b: &str) -> f32 {
188    if a.is_empty() || b.is_empty() {
189        return 0.0;
190    }
191    let ngrams_a: std::collections::HashSet<&str> = a
192        .as_bytes()
193        .windows(3)
194        .map(|w| std::str::from_utf8(w).unwrap_or(""))
195        .collect();
196    let ngrams_b: std::collections::HashSet<&str> = b
197        .as_bytes()
198        .windows(3)
199        .map(|w| std::str::from_utf8(w).unwrap_or(""))
200        .collect();
201    let intersection = ngrams_a.intersection(&ngrams_b).count();
202    let union = ngrams_a.union(&ngrams_b).count();
203    if union == 0 {
204        0.0
205    } else {
206        intersection as f32 / union as f32
207    }
208}
209
210// ── boundary.enforce ─────────────────────────────────────────────────
211
212/// Enforce resource boundaries and report violations.
213///
214/// Checks galaxy memory counts, tag sprawl, and content size against
215/// configurable limits. Returns violations and recommendations.
216pub struct BoundaryEnforceTool {
217    store: Arc<MemoryStore>,
218    stats: ToolStats,
219    effects: EffectRow,
220}
221
222impl BoundaryEnforceTool {
223    pub fn new(store: Arc<MemoryStore>) -> Self {
224        Self {
225            store,
226            stats: ToolStats::default(),
227            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
228        }
229    }
230}
231
232#[async_trait]
233impl Tool for BoundaryEnforceTool {
234    fn name(&self) -> &str {
235        "boundary.enforce"
236    }
237    fn gana(&self) -> Gana {
238        Gana::Wall
239    }
240    fn effects(&self) -> &EffectRow {
241        &self.effects
242    }
243    fn description(&self) -> &str {
244        "Enforce resource boundaries and report violations"
245    }
246    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
247        let max_memories_per_galaxy = args
248            .get("max_memories_per_galaxy")
249            .and_then(serde_json::Value::as_u64)
250            .unwrap_or(10000);
251        let max_tag_sprawl = args
252            .get("max_tag_sprawl")
253            .and_then(serde_json::Value::as_u64)
254            .unwrap_or(100);
255        let max_content_length = args
256            .get("max_content_length")
257            .and_then(serde_json::Value::as_u64)
258            .unwrap_or(10000) as usize;
259        let max_tags_per_memory = args
260            .get("max_tags_per_memory")
261            .and_then(serde_json::Value::as_u64)
262            .unwrap_or(20) as usize;
263
264        let mut violations: Vec<Value> = Vec::new();
265        let mut galaxy_reports: Vec<Value> = Vec::new();
266        let mut total_memories = 0u64;
267        let mut total_tags: std::collections::HashSet<String> = std::collections::HashSet::new();
268
269        for galaxy in Galaxy::memory_galaxies() {
270            let mems = self.store.scan(galaxy, 100000)?;
271            let count = mems.len() as u64;
272            total_memories += count;
273
274            let mut galaxy_tags: std::collections::HashSet<String> =
275                std::collections::HashSet::new();
276            let mut oversized_count = 0u64;
277            let mut overtagged_count = 0u64;
278
279            for mem in &mems {
280                for tag in &mem.metadata.tags {
281                    galaxy_tags.insert(tag.clone());
282                    total_tags.insert(tag.clone());
283                }
284                if mem.content.len() > max_content_length {
285                    oversized_count += 1;
286                }
287                if mem.metadata.tags.len() > max_tags_per_memory {
288                    overtagged_count += 1;
289                }
290            }
291
292            // Check galaxy memory limit
293            if count > max_memories_per_galaxy {
294                violations.push(json!({
295                    "type": "galaxy_overflow",
296                    "galaxy": galaxy_name(galaxy),
297                    "count": count,
298                    "limit": max_memories_per_galaxy,
299                    "severity": "high",
300                    "recommendation": format!("Galaxy '{}' has {} memories (limit: {}) — consider consolidation or pruning", galaxy_name(galaxy), count, max_memories_per_galaxy),
301                }));
302            }
303
304            // Check oversized memories
305            if oversized_count > 0 {
306                violations.push(json!({
307                    "type": "oversized_memories",
308                    "galaxy": galaxy_name(galaxy),
309                    "count": oversized_count,
310                    "max_length": max_content_length,
311                    "severity": "medium",
312                    "recommendation": format!("{} memories in '{}' exceed max content length ({})", oversized_count, galaxy_name(galaxy), max_content_length),
313                }));
314            }
315
316            // Check over-tagged memories
317            if overtagged_count > 0 {
318                violations.push(json!({
319                    "type": "overtagged_memories",
320                    "galaxy": galaxy_name(galaxy),
321                    "count": overtagged_count,
322                    "max_tags": max_tags_per_memory,
323                    "severity": "low",
324                }));
325            }
326
327            galaxy_reports.push(json!({
328                "galaxy": galaxy_name(galaxy),
329                "count": count,
330                "unique_tags": galaxy_tags.len(),
331                "oversized": oversized_count,
332                "overtagged": overtagged_count,
333            }));
334        }
335
336        // Check global tag sprawl
337        if total_tags.len() as u64 > max_tag_sprawl {
338            violations.push(json!({
339                "type": "tag_sprawl",
340                "total_unique_tags": total_tags.len(),
341                "limit": max_tag_sprawl,
342                "severity": "medium",
343                "recommendation": format!("{} unique tags across all galaxies (limit: {}) — consider tag consolidation", total_tags.len(), max_tag_sprawl),
344            }));
345        }
346
347        let all_clear = violations.is_empty();
348
349        Ok(json!({
350            "status": "success",
351            "all_clear": all_clear,
352            "total_memories": total_memories,
353            "total_unique_tags": total_tags.len(),
354            "violation_count": violations.len(),
355            "violations": violations,
356            "galaxy_reports": galaxy_reports,
357            "limits": {
358                "max_memories_per_galaxy": max_memories_per_galaxy,
359                "max_tag_sprawl": max_tag_sprawl,
360                "max_content_length": max_content_length,
361                "max_tags_per_memory": max_tags_per_memory,
362            },
363        }))
364    }
365    fn stats(&self) -> &ToolStats {
366        &self.stats
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
375        let tmp = tempfile::tempdir().unwrap();
376        let store = MemoryStore::open_default(tmp.path()).unwrap();
377        (tmp, Arc::new(store))
378    }
379
380    fn seed_normal(store: &Arc<MemoryStore>) {
381        for i in 0..5 {
382            let mut mem =
383                wm_memory::Memory::new(Galaxy::Codex, format!("Unique memory content number {i}"));
384            mem.metadata.tags = vec!["tag1".into(), "tag2".into()];
385            mem.metadata.importance = 0.5;
386            store.put(Galaxy::Codex, &mem).unwrap();
387        }
388    }
389
390    fn seed_duplicates(store: &Arc<MemoryStore>) {
391        for _ in 0..4 {
392            let mut mem =
393                wm_memory::Memory::new(Galaxy::Codex, "Duplicate content same text".into());
394            mem.metadata.tags = vec!["dup".into()];
395            store.put(Galaxy::Codex, &mem).unwrap();
396        }
397    }
398
399    #[tokio::test]
400    async fn anti_loop_detects_exact_duplicates() {
401        let (_tmp, store) = open_store();
402        seed_duplicates(&store);
403
404        let tool = AntiLoopCheckTool::new(store);
405        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
406        let obj = result.as_object().unwrap();
407        assert_eq!(obj["status"], "success");
408        let warnings = obj["warnings"].as_array().unwrap();
409        let has_dup = warnings.iter().any(|w| w["type"] == "exact_duplicate");
410        assert!(has_dup, "Should detect exact duplicates");
411    }
412
413    #[tokio::test]
414    async fn anti_loop_no_issues() {
415        let (_tmp, store) = open_store();
416        seed_normal(&store);
417
418        let tool = AntiLoopCheckTool::new(store);
419        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
420        let obj = result.as_object().unwrap();
421        assert_eq!(obj["status"], "success");
422        assert_eq!(obj["loop_detected"], false);
423    }
424
425    #[tokio::test]
426    async fn anti_loop_empty_store() {
427        let (_tmp, store) = open_store();
428        let tool = AntiLoopCheckTool::new(store);
429        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
430        let obj = result.as_object().unwrap();
431        assert_eq!(obj["total_memories"], 0);
432        assert_eq!(obj["loop_detected"], false);
433    }
434
435    #[tokio::test]
436    async fn anti_loop_detects_near_duplicates() {
437        let (_tmp, store) = open_store();
438        let mut mem1 =
439            wm_memory::Memory::new(Galaxy::Codex, "Rust programming language features".into());
440        mem1.metadata.importance = 0.8;
441        store.put(Galaxy::Codex, &mem1).unwrap();
442        let mut mem2 =
443            wm_memory::Memory::new(Galaxy::Codex, "Rust programming language basics".into());
444        mem2.metadata.importance = 0.8;
445        store.put(Galaxy::Codex, &mem2).unwrap();
446
447        let tool = AntiLoopCheckTool::new(store);
448        let result = tool
449            .call(
450                &mut Context::default(),
451                json!({"similarity_threshold": 0.5}),
452            )
453            .await
454            .unwrap();
455        let obj = result.as_object().unwrap();
456        let warnings = obj["warnings"].as_array().unwrap();
457        let has_near = warnings.iter().any(|w| w["type"] == "near_duplicate");
458        assert!(has_near, "Should detect near-duplicates");
459    }
460
461    #[tokio::test]
462    async fn boundary_enforce_all_clear() {
463        let (_tmp, store) = open_store();
464        seed_normal(&store);
465
466        let tool = BoundaryEnforceTool::new(store);
467        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
468        let obj = result.as_object().unwrap();
469        assert_eq!(obj["status"], "success");
470        assert_eq!(obj["all_clear"], true);
471        assert_eq!(obj["violation_count"], 0);
472    }
473
474    #[tokio::test]
475    async fn boundary_enforce_detects_oversized() {
476        let (_tmp, store) = open_store();
477        let mut mem = wm_memory::Memory::new(Galaxy::Codex, "A".repeat(100));
478        mem.metadata.importance = 0.5;
479        store.put(Galaxy::Codex, &mem).unwrap();
480
481        let tool = BoundaryEnforceTool::new(store);
482        let result = tool
483            .call(&mut Context::default(), json!({"max_content_length": 50}))
484            .await
485            .unwrap();
486        let obj = result.as_object().unwrap();
487        assert_eq!(obj["all_clear"], false);
488        let violations = obj["violations"].as_array().unwrap();
489        let has_oversized = violations.iter().any(|v| v["type"] == "oversized_memories");
490        assert!(has_oversized, "Should detect oversized memories");
491    }
492
493    #[tokio::test]
494    async fn boundary_enforce_detects_overtagged() {
495        let (_tmp, store) = open_store();
496        let mut mem = wm_memory::Memory::new(Galaxy::Codex, "Test memory".into());
497        mem.metadata.tags = (0..25).map(|i| format!("tag{i}")).collect();
498        store.put(Galaxy::Codex, &mem).unwrap();
499
500        let tool = BoundaryEnforceTool::new(store);
501        let result = tool
502            .call(&mut Context::default(), json!({"max_tags_per_memory": 20}))
503            .await
504            .unwrap();
505        let obj = result.as_object().unwrap();
506        let violations = obj["violations"].as_array().unwrap();
507        let has_overtagged = violations
508            .iter()
509            .any(|v| v["type"] == "overtagged_memories");
510        assert!(has_overtagged, "Should detect over-tagged memories");
511    }
512
513    #[tokio::test]
514    async fn boundary_enforce_empty_store() {
515        let (_tmp, store) = open_store();
516        let tool = BoundaryEnforceTool::new(store);
517        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
518        let obj = result.as_object().unwrap();
519        assert_eq!(obj["all_clear"], true);
520        assert_eq!(obj["total_memories"], 0);
521    }
522
523    #[tokio::test]
524    async fn boundary_enforce_galaxy_reports() {
525        let (_tmp, store) = open_store();
526        seed_normal(&store);
527
528        let tool = BoundaryEnforceTool::new(store);
529        let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
530        let obj = result.as_object().unwrap();
531        let reports = obj["galaxy_reports"].as_array().unwrap();
532        let codex = reports.iter().find(|r| r["galaxy"] == "codex");
533        assert!(codex.is_some());
534        assert_eq!(codex.unwrap()["count"], 5);
535    }
536}