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
13/// Convert resource-native records into [`ContextItem`] values.
14pub trait IntoContextItem {
15    /// Project this resource record into a prompt-ready context item.
16    fn to_context_item(&self) -> ContextItem;
17}
18
19impl IntoContextItem for BehaviorPattern {
20    fn to_context_item(&self) -> ContextItem {
21        let source_id = format!("behavior_pattern/{}@v{}", self.id, self.version);
22        let text = if self.description.is_empty() {
23            format!("behavior pattern {} version {}", self.id, self.version)
24        } else {
25            self.description.clone()
26        };
27        ContextItem::new(ContextSourceKind::Resource, source_id, text)
28            .with_score(f64::from(self.confidence_delta))
29            .with_provenance(json!({
30                "resource": "behavior_pattern",
31                "id": self.id,
32                "version": self.version,
33                "required": self.rule.required,
34                "forbidden": self.rule.forbidden,
35                "confidence_delta": self.confidence_delta,
36                "conclude": self.conclude,
37            }))
38    }
39}
40
41impl IntoContextItem for EntityBaseline {
42    fn to_context_item(&self) -> ContextItem {
43        ContextItem::new(
44            ContextSourceKind::Resource,
45            format!("baseline/{}/{}", self.entity, self.metric),
46            format!(
47                "baseline for {} {}: mean {}, std_dev {}, samples {}",
48                self.entity, self.metric, self.mean, self.std_dev, self.samples
49            ),
50        )
51        .with_score(self.samples as f64)
52        .with_provenance(json!({
53            "resource": "baseline",
54            "entity": self.entity,
55            "metric": self.metric,
56            "mean": self.mean,
57            "std_dev": self.std_dev,
58            "samples": self.samples,
59        }))
60    }
61}
62
63impl IntoContextItem for MemoryLookupHit {
64    fn to_context_item(&self) -> ContextItem {
65        memory_hit_to_context_item(self, 0)
66    }
67}
68
69#[cfg(feature = "graph")]
70impl IntoContextItem for Subgraph {
71    fn to_context_item(&self) -> ContextItem {
72        subgraph_to_context_item(self, 0)
73    }
74}
75
76/// Project a memory lookup hit into a ranked memory context item.
77#[must_use]
78pub fn memory_hit_to_context_item(hit: &MemoryLookupHit, rank: usize) -> ContextItem {
79    let source_id = hit
80        .key
81        .clone()
82        .unwrap_or_else(|| format!("memory.hit/{rank}"));
83    ContextItem::new(ContextSourceKind::Memory, source_id, hit.summary.clone())
84        .with_rank(rank)
85        .with_score(f64::from(hit.score))
86        .with_provenance(json!({
87            "resource": "memory.lookup",
88            "key": hit.key,
89            "score": hit.score,
90            "metadata": hit.metadata,
91        }))
92}
93
94/// Project memory lookup hits into ranked memory context items.
95#[must_use]
96pub fn memory_hits_to_context_items(hits: &[MemoryLookupHit]) -> Vec<ContextItem> {
97    hits.iter()
98        .enumerate()
99        .map(|(rank, hit)| memory_hit_to_context_item(hit, rank))
100        .collect()
101}
102
103/// Project all accumulated investigation evidence into resource or memory
104/// context items.
105#[must_use]
106pub fn evidence_to_context_items(ctx: &InvestigationContext) -> Vec<ContextItem> {
107    ctx.evidence
108        .iter()
109        .enumerate()
110        .map(|(rank, evidence)| evidence_to_context_item(evidence, rank))
111        .collect()
112}
113
114/// Project a graph expansion into a resource context item.
115#[cfg(feature = "graph")]
116#[must_use]
117pub fn subgraph_to_context_item(subgraph: &Subgraph, rank: usize) -> ContextItem {
118    let node_count = subgraph.nodes.len();
119    let edge_count = subgraph.edges.len();
120    ContextItem::new(
121        ContextSourceKind::Resource,
122        format!("graph/{}", subgraph.seed),
123        format!(
124            "graph expansion for {}: {} nodes, {} edges",
125            subgraph.seed, node_count, edge_count
126        ),
127    )
128    .with_rank(rank)
129    .with_score(node_count.saturating_add(edge_count) as f64)
130    .with_provenance(json!({
131        "resource": "graph.subgraph",
132        "seed": subgraph.seed,
133        "nodes": subgraph.nodes,
134        "edges": subgraph.edges,
135    }))
136}
137
138/// Project one evidence record into a context item.
139#[must_use]
140pub fn evidence_to_context_item(evidence: &Evidence, rank: usize) -> ContextItem {
141    let source = if evidence.source_skill == "general.memory_pivot"
142        || evidence.label.starts_with("memory.")
143    {
144        ContextSourceKind::Memory
145    } else {
146        ContextSourceKind::Resource
147    };
148    let source_id = format!("{}/{}", evidence.source_skill, evidence.label);
149    ContextItem::new(source, source_id, evidence_text(evidence))
150        .with_rank(rank)
151        .with_score(evidence_score(&evidence.detail))
152        .with_provenance(json!({
153            "source_skill": evidence.source_skill,
154            "label": evidence.label,
155            "detail": evidence.detail,
156        }))
157}
158
159/// Pack resource-projected context items with the shared kernel packer.
160#[must_use]
161pub fn pack_resource_context(items: Vec<ContextItem>, config: ContextPackConfig) -> ContextPack {
162    ContextPack::pack(items, config)
163}
164
165fn evidence_text(evidence: &Evidence) -> String {
166    evidence
167        .detail
168        .get("summary")
169        .and_then(Value::as_str)
170        .or_else(|| evidence.detail.get("description").and_then(Value::as_str))
171        .map(str::to_owned)
172        .unwrap_or_else(|| evidence.label.clone())
173}
174
175fn evidence_score(detail: &Value) -> f64 {
176    detail
177        .get("score")
178        .and_then(Value::as_f64)
179        .or_else(|| detail.get("delta").and_then(Value::as_f64))
180        .or_else(|| detail.get("confidence_delta").and_then(Value::as_f64))
181        .unwrap_or(0.0)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::PatternRule;
188    use rig_compose::ContextOmissionReason;
189
190    #[test]
191    fn behavior_pattern_projects_to_resource_context() {
192        let pattern = BehaviorPattern::new(
193            "spray",
194            2,
195            PatternRule {
196                required: vec!["auth.failure.burst".into()],
197                forbidden: vec!["baseline.within".into()],
198            },
199            0.25,
200        )
201        .with_description("password spray around one host");
202
203        let item = pattern.to_context_item();
204
205        assert_eq!(item.source, ContextSourceKind::Resource);
206        assert_eq!(item.source_id, "behavior_pattern/spray@v2");
207        assert_eq!(item.text, "password spray around one host");
208        assert!((item.score - 0.25).abs() < 1e-9);
209        assert_eq!(item.provenance["resource"], "behavior_pattern");
210        assert_eq!(item.provenance["required"][0], "auth.failure.burst");
211    }
212
213    #[test]
214    fn memory_hits_project_with_stable_ranks() {
215        let hits = vec![
216            MemoryLookupHit::new(0.9, "first").with_key("episode-1"),
217            MemoryLookupHit::new(0.5, "second"),
218        ];
219
220        let items = memory_hits_to_context_items(&hits);
221
222        assert_eq!(items.len(), 2);
223        assert_eq!(items[0].source, ContextSourceKind::Memory);
224        assert_eq!(items[0].source_id, "episode-1");
225        assert_eq!(items[0].rank, 0);
226        assert_eq!(items[1].source_id, "memory.hit/1");
227        assert_eq!(items[1].rank, 1);
228    }
229
230    #[test]
231    fn evidence_projection_packs_and_omits_by_kernel_rules() {
232        let mut ctx = InvestigationContext::new("host", "partition");
233        ctx.evidence.push(
234            Evidence::new("general.memory_pivot", "memory.hit")
235                .with_detail(json!({"summary": "matching episode", "score": 0.8})),
236        );
237        ctx.evidence.push(
238            Evidence::new("knowledge.behavior_pattern", "pattern:spray")
239                .with_detail(json!({"description": "spray pattern", "delta": 0.2})),
240        );
241
242        let items = evidence_to_context_items(&ctx);
243        let pack = pack_resource_context(items, ContextPackConfig::new(1_000).with_max_items(1));
244
245        assert_eq!(pack.selected.len(), 1);
246        assert_eq!(pack.omitted.len(), 1);
247        assert_eq!(pack.omitted[0].reason, ContextOmissionReason::MaxItems);
248        assert_eq!(pack.selected[0].source, ContextSourceKind::Memory);
249        assert_eq!(pack.selected[0].text, "matching episode");
250    }
251
252    #[cfg(feature = "graph")]
253    #[test]
254    fn subgraph_projects_to_resource_context() {
255        use crate::GraphEdge;
256
257        let subgraph = Subgraph {
258            seed: "host-1".into(),
259            nodes: vec!["host-1".into(), "host-2".into()],
260            edges: vec![GraphEdge::new("host-1", "host-2", "connects")],
261        };
262
263        let item = subgraph_to_context_item(&subgraph, 3);
264
265        assert_eq!(item.source, ContextSourceKind::Resource);
266        assert_eq!(item.source_id, "graph/host-1");
267        assert_eq!(item.rank, 3);
268        assert_eq!(item.score, 3.0);
269        assert_eq!(item.provenance["resource"], "graph.subgraph");
270        assert_eq!(item.provenance["seed"], "host-1");
271    }
272}