Skip to main content

rig_resources/
projection.rs

1//! Projection helpers for `rig-compose` context packing.
2
3use 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
17/// Convert resource-native records into [`ContextItem`] values.
18pub trait IntoContextItem {
19    /// Project this resource record into a prompt-ready context item.
20    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/// Project a memory lookup hit into a ranked memory context item.
89#[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/// Project memory lookup hits into ranked memory context items.
114#[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/// Project all accumulated investigation evidence into resource or memory
123/// context items.
124#[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/// Project a graph expansion into a resource context item.
134#[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/// Project one evidence record into a context item.
162#[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/// Pack resource-projected context items with the shared kernel packer.
187#[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}