rig_resources/
patterns.rs1use 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;
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct PatternRule {
21 #[serde(default)]
23 pub required: Vec<String>,
24 #[serde(default)]
26 pub forbidden: Vec<String>,
27}
28
29impl PatternRule {
30 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#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct BehaviorPattern {
40 pub id: PatternId,
42 pub version: u32,
44 pub description: String,
46 pub rule: PatternRule,
48 pub confidence_delta: f32,
50 #[serde(default)]
52 pub conclude: bool,
53}
54
55impl BehaviorPattern {
56 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 pub fn with_description(mut self, description: impl Into<String>) -> Self {
70 self.description = description.into();
71 self
72 }
73
74 pub fn concluding(mut self) -> Self {
76 self.conclude = true;
77 self
78 }
79}
80
81#[derive(Clone, Default)]
84pub struct BehaviorRegistry {
85 inner: Arc<RwLock<Vec<BehaviorPattern>>>,
86}
87
88impl BehaviorRegistry {
89 pub fn new() -> Self {
91 Self::default()
92 }
93
94 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 pub fn extend<I: IntoIterator<Item = BehaviorPattern>>(&self, patterns: I) {
109 for pattern in patterns {
110 self.register(pattern);
111 }
112 }
113
114 pub fn len(&self) -> usize {
116 self.inner.read().len()
117 }
118
119 pub fn is_empty(&self) -> bool {
121 self.inner.read().is_empty()
122 }
123
124 pub fn snapshot(&self) -> Vec<BehaviorPattern> {
126 self.inner.read().clone()
127 }
128}
129
130pub struct BehaviorPatternSkill {
133 registry: BehaviorRegistry,
134}
135
136impl BehaviorPatternSkill {
137 pub const ID: &'static str = "knowledge.behavior_pattern";
139
140 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}