Skip to main content

oris_evolution/gep/
capsule.rs

1//! GEP-compatible Capsule definition.
2//!
3//! A Capsule records a single successful evolution. It captures what triggered
4//! the evolution, which gene was used, the outcome, and the actual code changes.
5
6use super::content_hash::{compute_asset_id, AssetIdError};
7use super::gene::GeneCategory;
8use serde::{Deserialize, Serialize};
9
10/// Capsule content - structured description of the evolution
11#[derive(Clone, Debug, Serialize, Deserialize, Default)]
12pub struct CapsuleContent {
13    /// Intent of the evolution
14    #[serde(default)]
15    pub intent: String,
16    /// Strategy followed
17    #[serde(default)]
18    pub strategy: String,
19    /// Scope of changes
20    #[serde(default)]
21    pub scope: String,
22    /// Files that were changed
23    #[serde(default, rename = "changed_files")]
24    pub changed_files: Vec<String>,
25    /// Rationale behind changes
26    #[serde(default)]
27    pub rationale: String,
28    /// Outcome description
29    #[serde(default)]
30    pub outcome: String,
31}
32
33/// Trigger context - full context that triggered this evolution
34#[derive(Clone, Debug, Serialize, Deserialize, Default)]
35pub struct TriggerContext {
36    /// Original user/agent prompt (max 2000 chars)
37    #[serde(default)]
38    pub prompt: Option<String>,
39    /// Agent's reasoning chain before executing (max 4000 chars)
40    #[serde(default, rename = "reasoning_trace")]
41    pub reasoning_trace: Option<String>,
42    /// Additional contextual signals beyond trigger
43    #[serde(default, rename = "context_signals")]
44    pub context_signals: Vec<String>,
45    /// Session identifier for cross-session tracking
46    #[serde(default)]
47    pub session_id: Option<String>,
48    /// The LLM model used
49    #[serde(default, rename = "agent_model")]
50    pub agent_model: Option<String>,
51}
52
53/// Blast radius - scope of changes
54#[derive(Clone, Debug, Serialize, Deserialize, Default)]
55pub struct BlastRadius {
56    /// Number of files changed
57    pub files: usize,
58    /// Number of lines changed
59    pub lines: usize,
60}
61
62/// Outcome result
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct CapsuleOutcome {
65    /// Status: success or failed
66    pub status: CapsuleStatus,
67    /// Score from 0.0 to 1.0
68    pub score: f32,
69    /// Optional note
70    #[serde(default)]
71    pub note: Option<String>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum CapsuleStatus {
77    Success,
78    Failed,
79}
80
81impl Default for CapsuleOutcome {
82    fn default() -> Self {
83        Self {
84            status: CapsuleStatus::Failed,
85            score: 0.0,
86            note: None,
87        }
88    }
89}
90
91/// Environment fingerprint snapshot
92#[derive(Clone, Debug, Serialize, Deserialize, Default)]
93pub struct EnvFingerprint {
94    /// Runtime environment snapshot
95    #[serde(default)]
96    pub runtime: Option<String>,
97    /// OS version
98    #[serde(default)]
99    pub os: Option<String>,
100    /// Other env details
101    #[serde(flatten)]
102    pub extra: std::collections::HashMap<String, serde_json::Value>,
103}
104
105/// GEP-compatible Capsule definition
106#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct GepCapsule {
108    /// Asset type - always "Capsule"
109    #[serde(rename = "type")]
110    pub capsule_type: String,
111    /// Protocol schema version
112    #[serde(rename = "schema_version")]
113    pub schema_version: String,
114    /// Unique identifier (e.g., capsule_1708123456789)
115    pub id: String,
116    /// Signals that triggered this evolution
117    pub trigger: Vec<String>,
118    /// ID of the gene used
119    pub gene: String,
120    /// Human-readable description
121    pub summary: String,
122    /// Structured description
123    #[serde(default)]
124    pub content: Option<CapsuleContent>,
125    /// Git diff of actual code changes
126    #[serde(default)]
127    pub diff: Option<String>,
128    /// Ordered execution steps
129    #[serde(default)]
130    pub strategy: Option<Vec<String>>,
131    /// Confidence 0.0-1.0
132    pub confidence: f32,
133    /// Blast radius
134    #[serde(default, rename = "blast_radius")]
135    pub blast_radius: BlastRadius,
136    /// Outcome
137    pub outcome: CapsuleOutcome,
138    /// Consecutive successes with this gene
139    #[serde(default, rename = "success_streak")]
140    pub success_streak: Option<u32>,
141    /// Runtime environment snapshot
142    #[serde(default, rename = "env_fingerprint")]
143    pub env_fingerprint: Option<EnvFingerprint>,
144    /// LLM model that produced this capsule
145    #[serde(default, rename = "model_name")]
146    pub model_name: Option<String>,
147    /// Content-addressable hash
148    #[serde(rename = "asset_id")]
149    pub asset_id: String,
150    /// Trigger context (optional)
151    #[serde(default, rename = "trigger_context")]
152    pub trigger_context: Option<TriggerContext>,
153    /// ID of reused capsule (if reused)
154    #[serde(default, rename = "reused_asset_id")]
155    pub reused_asset_id: Option<String>,
156    /// Source type: generated, reused, or reference
157    #[serde(default, rename = "source_type")]
158    pub source_type: CapsuleSourceType,
159}
160
161#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
162#[serde(rename_all = "lowercase")]
163pub enum CapsuleSourceType {
164    Generated,
165    Reused,
166    Reference,
167}
168
169impl Default for CapsuleSourceType {
170    fn default() -> Self {
171        Self::Generated
172    }
173}
174
175impl GepCapsule {
176    /// Create a new GEP Capsule with computed asset_id
177    pub fn new(
178        id: String,
179        trigger: Vec<String>,
180        gene: String,
181        summary: String,
182        diff: String,
183        confidence: f32,
184    ) -> Result<Self, AssetIdError> {
185        let outcome = CapsuleOutcome {
186            status: CapsuleStatus::Success,
187            score: confidence,
188            note: None,
189        };
190
191        let mut capsule = Self {
192            capsule_type: "Capsule".to_string(),
193            schema_version: super::GEP_SCHEMA_VERSION.to_string(),
194            id,
195            trigger,
196            gene,
197            summary,
198            content: None,
199            diff: Some(diff),
200            strategy: None,
201            confidence,
202            blast_radius: BlastRadius::default(),
203            outcome,
204            success_streak: None,
205            env_fingerprint: None,
206            model_name: None,
207            asset_id: String::new(),
208            trigger_context: None,
209            reused_asset_id: None,
210            source_type: CapsuleSourceType::Generated,
211        };
212
213        capsule.asset_id = compute_asset_id(&capsule, &["asset_id"])?;
214        Ok(capsule)
215    }
216
217    /// Validate the capsule has minimum substance
218    pub fn validate(&self) -> Result<(), String> {
219        if self.id.is_empty() {
220            return Err("Capsule id cannot be empty".to_string());
221        }
222        if self.gene.is_empty() {
223            return Err("Capsule gene cannot be empty".to_string());
224        }
225
226        // Check substance requirement: at least one of content, diff, strategy, or code_snippet >= 50 chars
227        let has_substance = self
228            .content
229            .as_ref()
230            .map(|c| c.intent.len() + c.rationale.len() + c.outcome.len())
231            .unwrap_or(0)
232            >= 50
233            || self.diff.as_ref().map(|d| d.len()).unwrap_or(0) >= 50
234            || self
235                .strategy
236                .as_ref()
237                .map(|s| s.join("").len())
238                .unwrap_or(0)
239                >= 50;
240
241        if !has_substance {
242            return Err("Capsule must have at least 50 characters of substance".to_string());
243        }
244
245        Ok(())
246    }
247
248    /// Set the content
249    pub fn with_content(mut self, content: CapsuleContent) -> Self {
250        self.content = Some(content);
251        self
252    }
253
254    /// Set the strategy
255    pub fn with_strategy(mut self, strategy: Vec<String>) -> Self {
256        self.strategy = Some(strategy);
257        self
258    }
259
260    /// Set the blast radius
261    pub fn with_blast_radius(mut self, files: usize, lines: usize) -> Self {
262        self.blast_radius = BlastRadius { files, lines };
263        self
264    }
265
266    /// Set the trigger context
267    pub fn with_trigger_context(mut self, ctx: TriggerContext) -> Self {
268        self.trigger_context = Some(ctx);
269        self
270    }
271
272    /// Mark as reused capsule
273    pub fn as_reused(mut self, reused_id: String) -> Self {
274        self.source_type = CapsuleSourceType::Reused;
275        self.reused_asset_id = Some(reused_id);
276        self
277    }
278}
279
280/// Convert from Oris core Capsule to GEP Capsule
281impl From<&crate::Capsule> for GepCapsule {
282    fn from(oris_capsule: &crate::Capsule) -> Self {
283        let outcome = CapsuleOutcome {
284            status: if oris_capsule.outcome.success {
285                CapsuleStatus::Success
286            } else {
287                CapsuleStatus::Failed
288            },
289            score: oris_capsule.confidence,
290            note: None,
291        };
292
293        GepCapsule {
294            capsule_type: "Capsule".to_string(),
295            schema_version: super::GEP_SCHEMA_VERSION.to_string(),
296            id: oris_capsule.id.clone(),
297            trigger: vec![], // Placeholder
298            gene: oris_capsule.gene_id.clone(),
299            summary: format!("Capsule from mutation {}", oris_capsule.mutation_id),
300            content: None,
301            diff: Some(oris_capsule.diff_hash.clone()),
302            strategy: None,
303            confidence: oris_capsule.confidence,
304            blast_radius: BlastRadius {
305                files: oris_capsule.outcome.changed_files.len(),
306                lines: oris_capsule.outcome.lines_changed,
307            },
308            outcome,
309            success_streak: None,
310            env_fingerprint: Some(EnvFingerprint {
311                runtime: Some(oris_capsule.env.rustc_version.clone()),
312                os: Some(oris_capsule.env.os.clone()),
313                extra: std::collections::HashMap::new(),
314            }),
315            model_name: None,
316            asset_id: oris_capsule.diff_hash.clone(),
317            trigger_context: None,
318            reused_asset_id: None,
319            source_type: CapsuleSourceType::Generated,
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn test_capsule_creation() {
330        let capsule = GepCapsule::new(
331            "capsule_1708123456789".to_string(),
332            vec!["timeout".to_string(), "error".to_string()],
333            "gene_001".to_string(),
334            "Fixed connection timeout issue".to_string(),
335            "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,5 +1,5 @@\n".to_string(),
336            0.85,
337        )
338        .unwrap();
339
340        assert_eq!(capsule.capsule_type, "Capsule");
341        assert_eq!(capsule.schema_version, "1.5.0");
342        assert_eq!(capsule.outcome.status, CapsuleStatus::Success);
343    }
344
345    #[test]
346    fn test_capsule_validate() {
347        let capsule = GepCapsule::new(
348            "capsule_test".to_string(),
349            vec!["test".to_string()],
350            "gene_test".to_string(),
351            "Short".to_string(),
352            "short".to_string(), // Too short
353            0.5,
354        )
355        .unwrap();
356
357        assert!(capsule.validate().is_err());
358    }
359
360    #[test]
361    fn test_capsule_with_content() {
362        let capsule = GepCapsule::new(
363            "capsule_002".to_string(),
364            vec!["error".to_string()],
365            "gene_001".to_string(),
366            "Fixed bug".to_string(),
367            "diff content here that is definitely longer than fifty characters to pass validation"
368                .to_string(),
369            0.9,
370        )
371        .unwrap()
372        .with_content(CapsuleContent {
373            intent: "Fix error handling".to_string(),
374            strategy: "Add try-catch block".to_string(),
375            scope: "src/api.rs".to_string(),
376            changed_files: vec!["src/api.rs".to_string()],
377            rationale: "Error was unhandled".to_string(),
378            outcome: "Fixed".to_string(),
379        });
380
381        assert!(capsule.validate().is_ok());
382    }
383
384    #[test]
385    fn test_reused_capsule() {
386        let capsule = GepCapsule::new(
387            "capsule_reused".to_string(),
388            vec!["timeout".to_string()],
389            "gene_001".to_string(),
390            "Applied fix from capsule_001".to_string(),
391            "diff content that is definitely longer than fifty characters to pass validation checks".to_string(),
392            0.95,
393        ).unwrap()
394        .as_reused("capsule_original_001".to_string());
395
396        assert_eq!(capsule.source_type, CapsuleSourceType::Reused);
397        assert_eq!(
398            capsule.reused_asset_id,
399            Some("capsule_original_001".to_string())
400        );
401    }
402
403    #[test]
404    fn test_trigger_context() {
405        let ctx = TriggerContext {
406            prompt: Some("Fix the timeout bug".to_string()),
407            reasoning_trace: Some("Analyzed error logs, found timeout in connection".to_string()),
408            context_signals: vec!["signal1".to_string()],
409            session_id: Some("session_123".to_string()),
410            agent_model: Some("claude-sonnet-4".to_string()),
411        };
412
413        let capsule = GepCapsule::new(
414            "capsule_ctx".to_string(),
415            vec!["timeout".to_string()],
416            "gene_001".to_string(),
417            "Fixed with context".to_string(),
418            "diff content that is definitely longer than fifty characters to pass validation requirements".to_string(),
419            0.88,
420        ).unwrap()
421        .with_trigger_context(ctx);
422
423        assert!(capsule.trigger_context.is_some());
424        assert_eq!(
425            capsule.trigger_context.as_ref().unwrap().agent_model,
426            Some("claude-sonnet-4".to_string())
427        );
428    }
429}