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