vex_core/
genome_experiment.rs

1//! Genome experiment recording for evolution learning
2//!
3//! Records trait-fitness pairs as experiments that can be stored
4//! in temporal memory for pattern learning.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::evolution::Genome;
10
11/// A single genome experiment result
12///
13/// Records the genome traits used, the fitness scores achieved,
14/// and metadata about the experiment for later analysis.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GenomeExperiment {
17    /// The genome traits that were tested (copies of values, not names)
18    pub traits: Vec<f64>,
19    /// Trait names for reference
20    pub trait_names: Vec<String>,
21    /// Fitness scores by metric name (e.g., "task_completion": 0.8)
22    pub fitness_scores: HashMap<String, f64>,
23    /// Overall fitness (0.0-1.0) - weighted average of metrics
24    pub overall_fitness: f64,
25    /// Task description (truncated for storage efficiency)
26    pub task_summary: String,
27    /// Whether this was a successful experiment (fitness > threshold)
28    pub successful: bool,
29    /// Unique identifier for this experiment
30    pub id: uuid::Uuid,
31    /// Timestamp when experiment was recorded
32    pub timestamp: chrono::DateTime<chrono::Utc>,
33}
34
35impl GenomeExperiment {
36    /// Create a new genome experiment from a genome and fitness results
37    ///
38    /// # Arguments
39    /// * `genome` - The genome that was tested
40    /// * `fitness_scores` - Individual metric scores
41    /// * `overall` - Overall fitness score (0.0-1.0)
42    /// * `task` - Task description (will be truncated to 200 chars)
43    ///
44    /// # Example
45    /// ```
46    /// use vex_core::{Genome, GenomeExperiment};
47    /// use std::collections::HashMap;
48    ///
49    /// let genome = Genome::new("Test agent");
50    /// let mut scores = HashMap::new();
51    /// scores.insert("accuracy".to_string(), 0.85);
52    ///
53    /// let exp = GenomeExperiment::new(&genome, scores, 0.85, "Summarize document");
54    /// assert!(exp.successful);
55    /// ```
56    pub fn new(
57        genome: &Genome,
58        mut fitness_scores: HashMap<String, f64>,
59        overall: f64,
60        task: &str,
61    ) -> Self {
62        // Validate and sanitize fitness scores (security: prevent NaN/Infinity)
63        fitness_scores.retain(|k, v| {
64            !k.is_empty() &&           // No empty keys
65            k.len() < 100 &&           // Prevent memory DoS
66            v.is_finite() &&           // No NaN/Infinity
67            *v >= 0.0 &&               // Valid range
68            *v <= 1.0
69        });
70
71        // Sanitize overall fitness
72        let overall_fitness = if overall.is_finite() && (0.0..=1.0).contains(&overall) {
73            overall
74        } else {
75            0.5 // Safe default for invalid input
76        };
77
78        // Sanitize task summary (security: prevent log injection)
79        let sanitized_task: String = task
80            .chars()
81            .filter(|c| {
82                // Allow alphanumeric, whitespace, and safe punctuation
83                c.is_alphanumeric()
84                    || c.is_whitespace() && *c == ' ' // Only spaces, no CRLF
85                    || ".,!?-_:;()[]{}".contains(*c)
86            })
87            .take(200)
88            .collect();
89
90        Self {
91            traits: genome.traits.clone(),
92            trait_names: genome.trait_names.clone(),
93            fitness_scores,
94            overall_fitness,
95            task_summary: sanitized_task,
96            successful: overall_fitness > 0.6,
97            id: uuid::Uuid::new_v4(),
98            timestamp: chrono::Utc::now(),
99        }
100    }
101
102    /// Create from raw values (for deserialization or testing)
103    pub fn from_raw(
104        traits: Vec<f64>,
105        trait_names: Vec<String>,
106        overall_fitness: f64,
107        task_summary: &str,
108    ) -> Self {
109        Self {
110            traits,
111            trait_names,
112            fitness_scores: HashMap::new(),
113            overall_fitness,
114            task_summary: task_summary.to_string(),
115            successful: overall_fitness > 0.6,
116            id: uuid::Uuid::new_v4(),
117            timestamp: chrono::Utc::now(),
118        }
119    }
120
121    /// Get a specific trait value by name
122    pub fn get_trait(&self, name: &str) -> Option<f64> {
123        self.trait_names
124            .iter()
125            .position(|n| n == name)
126            .and_then(|i| self.traits.get(i).copied())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_experiment_creation() {
136        let genome = Genome::new("Test");
137        let mut scores = HashMap::new();
138        scores.insert("accuracy".to_string(), 0.9);
139        scores.insert("coherence".to_string(), 0.8);
140
141        let exp = GenomeExperiment::new(&genome, scores, 0.85, "Test task");
142
143        assert_eq!(exp.traits.len(), 5);
144        assert_eq!(exp.overall_fitness, 0.85);
145        assert!(exp.successful);
146        assert_eq!(exp.task_summary, "Test task");
147    }
148
149    #[test]
150    fn test_get_trait() {
151        let genome = Genome::new("Test");
152        let exp = GenomeExperiment::new(&genome, HashMap::new(), 0.5, "Task");
153
154        assert!(exp.get_trait("exploration").is_some());
155        assert_eq!(exp.get_trait("exploration"), Some(0.5));
156        assert!(exp.get_trait("nonexistent").is_none());
157    }
158
159    #[test]
160    fn test_success_threshold() {
161        let genome = Genome::new("Test");
162
163        let success = GenomeExperiment::new(&genome, HashMap::new(), 0.7, "Task");
164        assert!(success.successful);
165
166        let failure = GenomeExperiment::new(&genome, HashMap::new(), 0.5, "Task");
167        assert!(!failure.successful);
168    }
169
170    // === SECURITY TESTS ===
171
172    #[test]
173    fn test_task_injection_sanitized() {
174        let genome = Genome::new("test");
175        let malicious = "Task\x00\n\rINJECTED\x1b[31mRED";
176        let exp = GenomeExperiment::new(&genome, HashMap::new(), 0.5, malicious);
177
178        assert!(!exp.task_summary.contains('\x00'), "Null byte not removed");
179        assert!(!exp.task_summary.contains('\n'), "Newline not removed");
180        assert!(
181            !exp.task_summary.contains('\r'),
182            "Carriage return not removed"
183        );
184        assert!(
185            !exp.task_summary.contains('\x1b'),
186            "Escape sequence not removed"
187        );
188    }
189
190    #[test]
191    fn test_fitness_nan_validation() {
192        let genome = Genome::new("test");
193        let mut scores = HashMap::new();
194        scores.insert("nan_metric".to_string(), f64::NAN);
195        scores.insert("inf_metric".to_string(), f64::INFINITY);
196        scores.insert("valid_metric".to_string(), 0.8);
197        scores.insert("out_of_range".to_string(), 1.5);
198
199        let exp = GenomeExperiment::new(&genome, scores, f64::NAN, "task");
200
201        // NaN/Inf should be filtered out
202        assert!(!exp.fitness_scores.contains_key("nan_metric"));
203        assert!(!exp.fitness_scores.contains_key("inf_metric"));
204        assert!(!exp.fitness_scores.contains_key("out_of_range"));
205
206        // Valid should be kept
207        assert_eq!(exp.fitness_scores.get("valid_metric"), Some(&0.8));
208
209        // Overall fitness should default to 0.5 for NaN
210        assert_eq!(exp.overall_fitness, 0.5);
211    }
212
213    #[test]
214    fn test_fitness_key_length_limit() {
215        let genome = Genome::new("test");
216        let mut scores = HashMap::new();
217        scores.insert("A".repeat(200), 0.5); // Too long
218        scores.insert("valid_key".to_string(), 0.8);
219        scores.insert("".to_string(), 0.9); // Empty
220
221        let exp = GenomeExperiment::new(&genome, scores, 0.5, "task");
222
223        // Long and empty keys should be filtered
224        assert!(exp.fitness_scores.len() == 1);
225        assert_eq!(exp.fitness_scores.get("valid_key"), Some(&0.8));
226    }
227}