1use rig_compose::{
4 ContextItem, ContextPack, ContextPackConfig, ContextSourceKind, Evidence, InvestigationContext,
5};
6use serde_json::{Value, json};
7
8use crate::{BehaviorPattern, EntityBaseline, MemoryLookupHit};
9
10#[cfg(feature = "graph")]
11use crate::Subgraph;
12
13const STATE_CANDIDATE: &str = "candidate";
14#[cfg(feature = "graph")]
15const STATE_EXPANDED: &str = "expanded";
16
17pub trait IntoContextItem {
19 fn to_context_item(&self) -> ContextItem;
21}
22
23impl IntoContextItem for BehaviorPattern {
24 fn to_context_item(&self) -> ContextItem {
25 let source_id = format!("behavior_pattern/{}@v{}", self.id, self.version);
26 let text = if self.description.is_empty() {
27 format!("behavior pattern {} version {}", self.id, self.version)
28 } else {
29 self.description.clone()
30 };
31 ContextItem::new(ContextSourceKind::Resource, source_id, text)
32 .with_score(f64::from(self.confidence_delta))
33 .with_provenance(json!({
34 "resource": "behavior_pattern",
35 "source_uri": format!("behavior-pattern://{}@v{}", self.id, self.version),
36 "confidence": self.confidence_delta,
37 "version_key": self.id,
38 "projection_state": STATE_CANDIDATE,
39 "id": self.id,
40 "version": self.version,
41 "required": self.rule.required,
42 "forbidden": self.rule.forbidden,
43 "confidence_delta": self.confidence_delta,
44 "conclude": self.conclude,
45 }))
46 }
47}
48
49impl IntoContextItem for EntityBaseline {
50 fn to_context_item(&self) -> ContextItem {
51 ContextItem::new(
52 ContextSourceKind::Resource,
53 format!("baseline/{}/{}", self.entity, self.metric),
54 format!(
55 "baseline for {} {}: mean {}, std_dev {}, samples {}",
56 self.entity, self.metric, self.mean, self.std_dev, self.samples
57 ),
58 )
59 .with_score(self.samples as f64)
60 .with_provenance(json!({
61 "resource": "baseline",
62 "source_uri": format!("baseline://{}/{}", self.entity, self.metric),
63 "principal": self.entity,
64 "confidence": self.samples,
65 "projection_state": STATE_CANDIDATE,
66 "entity": self.entity,
67 "metric": self.metric,
68 "mean": self.mean,
69 "std_dev": self.std_dev,
70 "samples": self.samples,
71 }))
72 }
73}
74
75impl IntoContextItem for MemoryLookupHit {
76 fn to_context_item(&self) -> ContextItem {
77 memory_hit_to_context_item(self, 0)
78 }
79}
80
81#[cfg(feature = "graph")]
82impl IntoContextItem for Subgraph {
83 fn to_context_item(&self) -> ContextItem {
84 subgraph_to_context_item(self, 0)
85 }
86}
87
88#[must_use]
90pub fn memory_hit_to_context_item(hit: &MemoryLookupHit, rank: usize) -> ContextItem {
91 let source_id = hit
92 .key
93 .clone()
94 .unwrap_or_else(|| format!("memory.hit/{rank}"));
95 ContextItem::new(ContextSourceKind::Memory, source_id, hit.summary.clone())
96 .with_rank(rank)
97 .with_score(f64::from(hit.score))
98 .with_provenance(json!({
99 "resource": "memory.lookup",
100 "key": hit.key,
101 "source_uri": hit.source_uri,
102 "principal": hit.principal,
103 "scope": hit.scope,
104 "recorded_at_millis": hit.recorded_at_millis,
105 "confidence": hit.score,
106 "source_frame_id": hit.key,
107 "projection_state": STATE_CANDIDATE,
108 "score": hit.score,
109 "metadata": hit.metadata,
110 }))
111}
112
113#[must_use]
115pub fn memory_hits_to_context_items(hits: &[MemoryLookupHit]) -> Vec<ContextItem> {
116 hits.iter()
117 .enumerate()
118 .map(|(rank, hit)| memory_hit_to_context_item(hit, rank))
119 .collect()
120}
121
122#[must_use]
125pub fn evidence_to_context_items(ctx: &InvestigationContext) -> Vec<ContextItem> {
126 ctx.evidence
127 .iter()
128 .enumerate()
129 .map(|(rank, evidence)| evidence_to_context_item(evidence, rank))
130 .collect()
131}
132
133#[cfg(feature = "graph")]
135#[must_use]
136pub fn subgraph_to_context_item(subgraph: &Subgraph, rank: usize) -> ContextItem {
137 let node_count = subgraph.nodes.len();
138 let edge_count = subgraph.edges.len();
139 ContextItem::new(
140 ContextSourceKind::Resource,
141 format!("graph/{}", subgraph.seed),
142 format!(
143 "graph expansion for {}: {} nodes, {} edges",
144 subgraph.seed, node_count, edge_count
145 ),
146 )
147 .with_rank(rank)
148 .with_score(node_count.saturating_add(edge_count) as f64)
149 .with_provenance(json!({
150 "resource": "graph.subgraph",
151 "source_uri": format!("graph://{}", subgraph.seed),
152 "principal": subgraph.seed,
153 "projection_state": STATE_EXPANDED,
154 "reason": "graph_expansion",
155 "seed": subgraph.seed,
156 "nodes": subgraph.nodes,
157 "edges": subgraph.edges,
158 }))
159}
160
161#[must_use]
163pub fn evidence_to_context_item(evidence: &Evidence, rank: usize) -> ContextItem {
164 let source = if evidence.source_skill == "general.memory_pivot"
165 || evidence.label.starts_with("memory.")
166 {
167 ContextSourceKind::Memory
168 } else {
169 ContextSourceKind::Resource
170 };
171 let source_id = format!("{}/{}", evidence.source_skill, evidence.label);
172 ContextItem::new(source, source_id, evidence_text(evidence))
173 .with_rank(rank)
174 .with_score(evidence_score(&evidence.detail))
175 .with_provenance(json!({
176 "resource": "investigation.evidence",
177 "source_uri": format!("evidence://{}/{}", evidence.source_skill, evidence.label),
178 "confidence": evidence_score(&evidence.detail),
179 "projection_state": STATE_CANDIDATE,
180 "source_skill": evidence.source_skill,
181 "label": evidence.label,
182 "detail": evidence.detail,
183 }))
184}
185
186#[must_use]
188pub fn pack_resource_context(items: Vec<ContextItem>, config: ContextPackConfig) -> ContextPack {
189 ContextPack::pack(items, config)
190}
191
192fn evidence_text(evidence: &Evidence) -> String {
193 evidence
194 .detail
195 .get("summary")
196 .and_then(Value::as_str)
197 .or_else(|| evidence.detail.get("description").and_then(Value::as_str))
198 .map(str::to_owned)
199 .unwrap_or_else(|| evidence.label.clone())
200}
201
202fn evidence_score(detail: &Value) -> f64 {
203 detail
204 .get("score")
205 .and_then(Value::as_f64)
206 .or_else(|| detail.get("delta").and_then(Value::as_f64))
207 .or_else(|| detail.get("confidence_delta").and_then(Value::as_f64))
208 .unwrap_or(0.0)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::PatternRule;
215 use rig_compose::ContextOmissionReason;
216
217 #[test]
218 fn behavior_pattern_projects_to_resource_context() {
219 let pattern = BehaviorPattern::new(
220 "spray",
221 2,
222 PatternRule {
223 required: vec!["auth.failure.burst".into()],
224 forbidden: vec!["baseline.within".into()],
225 },
226 0.25,
227 )
228 .with_description("password spray around one host");
229
230 let item = pattern.to_context_item();
231
232 assert_eq!(item.source, ContextSourceKind::Resource);
233 assert_eq!(item.source_id, "behavior_pattern/spray@v2");
234 assert_eq!(item.text, "password spray around one host");
235 assert!((item.score - 0.25).abs() < 1e-9);
236 assert_eq!(item.provenance["resource"], "behavior_pattern");
237 assert_eq!(item.provenance["source_uri"], "behavior-pattern://spray@v2");
238 assert_eq!(item.provenance["projection_state"], "candidate");
239 assert_eq!(item.provenance["required"][0], "auth.failure.burst");
240 }
241
242 #[test]
243 fn memory_hits_project_with_stable_ranks() {
244 let hits = vec![
245 MemoryLookupHit::new(0.9, "first")
246 .with_key("episode-1")
247 .with_source_uri("memory://episode/1")
248 .with_principal("alice")
249 .with_scope("workspace")
250 .with_recorded_at_millis(1_700_000_000_000),
251 MemoryLookupHit::new(0.5, "second"),
252 ];
253
254 let items = memory_hits_to_context_items(&hits);
255
256 assert_eq!(items.len(), 2);
257 assert_eq!(items[0].source, ContextSourceKind::Memory);
258 assert_eq!(items[0].source_id, "episode-1");
259 assert_eq!(items[0].rank, 0);
260 assert_eq!(items[0].provenance["source_uri"], "memory://episode/1");
261 assert_eq!(items[0].provenance["principal"], "alice");
262 assert_eq!(items[0].provenance["scope"], "workspace");
263 assert_eq!(
264 items[0].provenance["recorded_at_millis"],
265 1_700_000_000_000_i64
266 );
267 let confidence = items[0].provenance["confidence"]
268 .as_f64()
269 .expect("confidence should serialize as a number");
270 assert!((confidence - 0.9).abs() < 1e-6);
271 assert_eq!(items[0].provenance["source_frame_id"], "episode-1");
272 assert_eq!(items[0].provenance["projection_state"], "candidate");
273 assert_eq!(items[1].source_id, "memory.hit/1");
274 assert_eq!(items[1].rank, 1);
275 }
276
277 #[test]
278 fn evidence_projection_packs_and_omits_by_kernel_rules() {
279 let mut ctx = InvestigationContext::new("host", "partition");
280 ctx.evidence.push(
281 Evidence::new("general.memory_pivot", "memory.hit")
282 .with_detail(json!({"summary": "matching episode", "score": 0.8})),
283 );
284 ctx.evidence.push(
285 Evidence::new("knowledge.behavior_pattern", "pattern:spray")
286 .with_detail(json!({"description": "spray pattern", "delta": 0.2})),
287 );
288
289 let items = evidence_to_context_items(&ctx);
290 let pack = pack_resource_context(items, ContextPackConfig::new(1_000).with_max_items(1));
291
292 assert_eq!(pack.selected.len(), 1);
293 assert_eq!(pack.omitted.len(), 1);
294 assert_eq!(pack.omitted[0].reason, ContextOmissionReason::MaxItems);
295 assert_eq!(pack.selected[0].source, ContextSourceKind::Memory);
296 assert_eq!(pack.selected[0].text, "matching episode");
297 }
298
299 #[cfg(feature = "graph")]
300 #[test]
301 fn subgraph_projects_to_resource_context() {
302 use crate::GraphEdge;
303
304 let subgraph = Subgraph {
305 seed: "host-1".into(),
306 nodes: vec!["host-1".into(), "host-2".into()],
307 edges: vec![GraphEdge::new("host-1", "host-2", "connects")],
308 };
309
310 let item = subgraph_to_context_item(&subgraph, 3);
311
312 assert_eq!(item.source, ContextSourceKind::Resource);
313 assert_eq!(item.source_id, "graph/host-1");
314 assert_eq!(item.rank, 3);
315 assert_eq!(item.score, 3.0);
316 assert_eq!(item.provenance["resource"], "graph.subgraph");
317 assert_eq!(item.provenance["source_uri"], "graph://host-1");
318 assert_eq!(item.provenance["projection_state"], "expanded");
319 assert_eq!(item.provenance["reason"], "graph_expansion");
320 assert_eq!(item.provenance["seed"], "host-1");
321 }
322}