Skip to main content

rig_resources/
patterns.rs

1//! Domain-neutral behavioural pattern primitives.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use parking_lot::RwLock;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use rig_compose::{
11    Evidence, InvestigationContext, KernelError, NextAction, Skill, SkillOutcome, ToolRegistry,
12};
13
14pub type PatternId = String;
15
16/// One rule clause: every signal in `required` must be present, and none
17/// of the signals in `forbidden` may be present.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct PatternRule {
20    #[serde(default)]
21    pub required: Vec<String>,
22    #[serde(default)]
23    pub forbidden: Vec<String>,
24}
25
26impl PatternRule {
27    pub fn matches(&self, ctx: &InvestigationContext) -> bool {
28        self.required.iter().all(|s| ctx.has_signal(s))
29            && self.forbidden.iter().all(|s| !ctx.has_signal(s))
30    }
31}
32
33/// One immutable behaviour pattern.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct BehaviorPattern {
36    pub id: PatternId,
37    pub version: u32,
38    pub description: String,
39    pub rule: PatternRule,
40    pub confidence_delta: f32,
41    #[serde(default)]
42    pub conclude: bool,
43}
44
45impl BehaviorPattern {
46    pub fn new(id: impl Into<String>, version: u32, rule: PatternRule, delta: f32) -> Self {
47        Self {
48            id: id.into(),
49            version,
50            description: String::new(),
51            rule,
52            confidence_delta: delta,
53            conclude: false,
54        }
55    }
56
57    pub fn with_description(mut self, description: impl Into<String>) -> Self {
58        self.description = description.into();
59        self
60    }
61
62    pub fn concluding(mut self) -> Self {
63        self.conclude = true;
64        self
65    }
66}
67
68/// Versioned, append-only registry of behaviour patterns. Cheap to clone
69/// (Arc-wrapped). `register` keeps the highest-version pattern per id.
70#[derive(Clone, Default)]
71pub struct BehaviorRegistry {
72    inner: Arc<RwLock<Vec<BehaviorPattern>>>,
73}
74
75impl BehaviorRegistry {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn register(&self, pattern: BehaviorPattern) {
81        let mut guard = self.inner.write();
82        if let Some(existing) = guard.iter_mut().find(|p| p.id == pattern.id) {
83            if pattern.version >= existing.version {
84                *existing = pattern;
85            }
86        } else {
87            guard.push(pattern);
88        }
89    }
90
91    pub fn extend<I: IntoIterator<Item = BehaviorPattern>>(&self, patterns: I) {
92        for pattern in patterns {
93            self.register(pattern);
94        }
95    }
96
97    pub fn len(&self) -> usize {
98        self.inner.read().len()
99    }
100
101    pub fn is_empty(&self) -> bool {
102        self.inner.read().is_empty()
103    }
104
105    pub fn snapshot(&self) -> Vec<BehaviorPattern> {
106        self.inner.read().clone()
107    }
108}
109
110/// Stateless skill that evaluates every registered pattern against the
111/// context.
112pub struct BehaviorPatternSkill {
113    registry: BehaviorRegistry,
114}
115
116impl BehaviorPatternSkill {
117    pub const ID: &'static str = "knowledge.behavior_pattern";
118
119    pub fn new(registry: BehaviorRegistry) -> Self {
120        Self { registry }
121    }
122}
123
124#[async_trait]
125impl Skill for BehaviorPatternSkill {
126    fn id(&self) -> &str {
127        Self::ID
128    }
129    fn description(&self) -> &str {
130        "Evaluates a behavioural-pattern registry against the investigation context."
131    }
132    fn applies(&self, _ctx: &InvestigationContext) -> bool {
133        !self.registry.is_empty()
134    }
135    async fn execute(
136        &self,
137        ctx: &mut InvestigationContext,
138        _tools: &ToolRegistry,
139    ) -> Result<SkillOutcome, KernelError> {
140        let _span = tracing::debug_span!(
141            "rig_resources.patterns.behavior_eval",
142            patterns = self.registry.len(),
143        )
144        .entered();
145        let matched: Vec<BehaviorPattern> = self
146            .registry
147            .snapshot()
148            .into_iter()
149            .filter(|pattern| pattern.rule.matches(ctx))
150            .collect();
151        let mut total = 0.0f32;
152        let mut conclude = false;
153        for pattern in matched {
154            total += pattern.confidence_delta;
155            conclude |= pattern.conclude;
156            ctx.evidence.push(
157                Evidence::new(Self::ID, format!("pattern:{}", pattern.id)).with_detail(json!({
158                    "version": pattern.version,
159                    "delta": pattern.confidence_delta,
160                })),
161            );
162        }
163        let mut outcome = SkillOutcome::default().with_delta(total);
164        if conclude {
165            outcome = outcome.with_next(NextAction::Conclude);
166        }
167        Ok(outcome)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn rule(required: &[&str]) -> PatternRule {
176        PatternRule {
177            required: required.iter().map(|s| s.to_string()).collect(),
178            forbidden: vec![],
179        }
180    }
181
182    #[tokio::test]
183    async fn matching_pattern_lifts_and_records_evidence() {
184        let reg = BehaviorRegistry::new();
185        reg.register(
186            BehaviorPattern::new("brute", 1, rule(&["auth.failure.burst"]), 0.25)
187                .with_description("password spray"),
188        );
189        let skill = BehaviorPatternSkill::new(reg);
190        let mut ctx = InvestigationContext::new("e", "p").with_signal("auth.failure.burst");
191        let tools = ToolRegistry::new();
192        let outcome = skill.execute(&mut ctx, &tools).await.unwrap();
193        assert!((outcome.confidence_delta - 0.25).abs() < 1e-6);
194        assert_eq!(ctx.evidence.len(), 1);
195    }
196
197    #[tokio::test]
198    async fn nonmatching_pattern_is_inert() {
199        let reg = BehaviorRegistry::new();
200        reg.register(BehaviorPattern::new("x", 1, rule(&["never"]), 0.5));
201        let skill = BehaviorPatternSkill::new(reg);
202        let mut ctx = InvestigationContext::new("e", "p");
203        let tools = ToolRegistry::new();
204        let outcome = skill.execute(&mut ctx, &tools).await.unwrap();
205        assert_eq!(outcome.confidence_delta, 0.0);
206        assert!(ctx.evidence.is_empty());
207    }
208
209    #[test]
210    fn registry_keeps_highest_version() {
211        let registry = BehaviorRegistry::new();
212        registry.register(BehaviorPattern::new("p", 1, PatternRule::default(), 0.1));
213        registry.register(BehaviorPattern::new("p", 2, PatternRule::default(), 0.2));
214        registry.register(BehaviorPattern::new("p", 1, PatternRule::default(), 0.9));
215        let snapshot = registry.snapshot();
216        assert_eq!(snapshot.len(), 1);
217        assert_eq!(snapshot[0].version, 2);
218        assert!((snapshot[0].confidence_delta - 0.2).abs() < 1e-6);
219    }
220
221    #[test]
222    fn forbidden_signal_blocks_match() {
223        let rule = PatternRule {
224            required: vec!["a".into()],
225            forbidden: vec!["b".into()],
226        };
227        let ctx_ok = InvestigationContext::new("e", "p").with_signal("a");
228        let ctx_block = InvestigationContext::new("e", "p")
229            .with_signal("a")
230            .with_signal("b");
231        assert!(rule.matches(&ctx_ok));
232        assert!(!rule.matches(&ctx_block));
233    }
234}