vex_core/
evolution_memory.rs

1//! Evolution memory using temporal decay
2//!
3//! Stores genome experiments with bio-inspired decay so recent
4//! high-performing experiments are weighted higher.
5//!
6//! This module requires the `evolution-memory` feature to be enabled.
7
8use std::collections::{HashMap, VecDeque};
9
10use crate::evolution::Genome;
11use crate::genome_experiment::GenomeExperiment;
12
13/// Memory for evolution experiments with importance-based learning
14///
15/// Unlike a simple vector of experiments, EvolutionMemory:
16/// - Weights recent experiments higher
17/// - Keeps high-fitness experiments longer
18/// - Learns trait-performance correlations
19/// - Suggests trait adjustments based on patterns
20///
21/// # Security
22/// Uses VecDeque for O(1) insertion and bounded capacity to prevent DoS attacks.
23#[derive(Debug, Clone, Default)]
24pub struct EvolutionMemory {
25    /// Stored experiments (most recent first) - VecDeque for O(1) push_front
26    experiments: VecDeque<(GenomeExperiment, f64)>, // (experiment, decayed_importance)
27    /// Maximum experiments to keep (DoS protection)
28    max_entries: usize,
29    /// Learned correlations between traits and fitness
30    correlations: HashMap<String, f64>,
31}
32
33impl EvolutionMemory {
34    /// Create new evolution memory with default capacity
35    pub fn new() -> Self {
36        Self {
37            experiments: VecDeque::new(),
38            max_entries: 500,
39            correlations: HashMap::new(),
40        }
41    }
42
43    /// Create with custom capacity
44    pub fn with_capacity(max_entries: usize) -> Self {
45        // Cap at 10,000 to prevent DoS
46        let safe_capacity = max_entries.min(10_000);
47        Self {
48            experiments: VecDeque::with_capacity(safe_capacity.min(100)),
49            max_entries: safe_capacity,
50            correlations: HashMap::new(),
51        }
52    }
53
54    /// Record a genome experiment
55    ///
56    /// High-fitness experiments get higher importance and are kept longer.
57    ///
58    /// # Security
59    /// Uses VecDeque::push_front for O(1) insertion (DoS prevention).
60    pub fn record(&mut self, experiment: GenomeExperiment) {
61        // Use fitness as importance (high performing = remembered longer)
62        let importance = experiment.overall_fitness;
63
64        // Add to front (most recent first) - O(1) with VecDeque
65        self.experiments.push_front((experiment, importance));
66
67        metrics::counter!("vex_experiments_recorded_total").increment(1);
68
69        // Evict old/low-importance if over capacity
70        self.maybe_evict();
71
72        // Update correlations periodically
73        if self.experiments.len() % 10 == 0 {
74            self.update_correlations();
75        }
76    }
77
78    /// Get top experiments by importance (recency + fitness)
79    pub fn get_top_experiments(&self, limit: usize) -> Vec<&GenomeExperiment> {
80        let mut sorted: Vec<_> = self.experiments.iter().collect();
81        sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
82        sorted.into_iter().take(limit).map(|(exp, _)| exp).collect()
83    }
84
85    /// Get all experiments
86    pub fn experiments(&self) -> impl Iterator<Item = &GenomeExperiment> {
87        self.experiments.iter().map(|(exp, _)| exp)
88    }
89
90    /// Number of stored experiments
91    pub fn len(&self) -> usize {
92        self.experiments.len()
93    }
94
95    /// Check if empty
96    pub fn is_empty(&self) -> bool {
97        self.experiments.is_empty()
98    }
99
100    /// Get learned trait correlations
101    pub fn correlations(&self) -> &HashMap<String, f64> {
102        &self.correlations
103    }
104
105    /// Get a snapshot of all experiments (for consolidation)
106    pub fn get_experiments_snapshot(&self) -> Vec<GenomeExperiment> {
107        self.experiments
108            .iter()
109            .map(|(exp, _)| exp.clone())
110            .collect()
111    }
112
113    /// Get snapshot of the oldest N experiments
114    pub fn get_experiments_oldest(&self, count: usize) -> Vec<GenomeExperiment> {
115        self.experiments
116            .iter()
117            .take(count)
118            .map(|(exp, _)| exp.clone())
119            .collect()
120    }
121
122    /// Clear all experiments (after consolidation)
123    /// Note: Keeps learned correlations map intact until new data arrives.
124    pub fn clear(&mut self) {
125        self.experiments.clear();
126        metrics::gauge!("vex_evolution_memory_size").set(0.0);
127    }
128
129    /// Remove the oldest N experiments (safer than clear)
130    pub fn drain_oldest(&mut self, count: usize) {
131        let actual_count = count.min(self.experiments.len());
132        self.experiments.drain(0..actual_count);
133        metrics::gauge!("vex_evolution_memory_size").set(self.experiments.len() as f64);
134    }
135
136    /// Calculate and update correlations between traits and fitness
137    fn update_correlations(&mut self) {
138        if self.experiments.len() < 10 {
139            return;
140        }
141
142        let trait_count = self
143            .experiments
144            .front()
145            .map(|(e, _)| e.traits.len())
146            .unwrap_or(5);
147
148        for i in 0..trait_count {
149            let trait_name = self
150                .experiments
151                .front()
152                .and_then(|(e, _)| e.trait_names.get(i).cloned())
153                .unwrap_or_else(|| format!("trait_{}", i));
154
155            let trait_values: Vec<f64> = self
156                .experiments
157                .iter()
158                .filter_map(|(e, _)| e.traits.get(i).copied())
159                .collect();
160            let fitness_values: Vec<f64> = self
161                .experiments
162                .iter()
163                .map(|(e, _)| e.overall_fitness)
164                .collect();
165
166            if trait_values.len() >= 10 {
167                let corr = pearson_correlation(&trait_values, &fitness_values);
168                self.correlations.insert(trait_name, corr);
169            }
170        }
171
172        metrics::gauge!("vex_learned_correlations_count").set(self.correlations.len() as f64);
173    }
174
175    /// Suggest trait adjustments based on learned correlations
176    pub fn suggest_adjustments(&self, current: &Genome) -> Vec<TraitAdjustment> {
177        self.correlations
178            .iter()
179            .filter(|(_, corr)| corr.abs() > 0.3) // Only strong correlations
180            .map(|(name, corr)| {
181                let current_val = current.get_trait(name).unwrap_or(0.5);
182                TraitAdjustment {
183                    trait_name: name.clone(),
184                    current_value: current_val,
185                    suggested_value: if *corr > 0.0 {
186                        (current_val + 0.1).min(1.0) // Increase positively correlated
187                    } else {
188                        (current_val - 0.1).max(0.0) // Decrease negatively correlated
189                    },
190                    correlation: *corr,
191                    confidence: corr.abs(),
192                }
193            })
194            .collect()
195    }
196
197    /// Evict low-importance experiments if over capacity
198    ///
199    /// # Security
200    /// Uses efficient O(n log n) sort + truncate instead of O(n²) loop to prevent DoS.
201    fn maybe_evict(&mut self) {
202        if self.experiments.len() > self.max_entries {
203            let initial_len = self.experiments.len();
204
205            // Convert to Vec for efficient sorting
206            let mut sorted: Vec<_> = self.experiments.drain(..).collect();
207
208            // Sort by importance (highest first)
209            sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
210
211            // Keep only top max_entries
212            sorted.truncate(self.max_entries);
213
214            // Convert back to VecDeque
215            self.experiments = VecDeque::from(sorted);
216
217            // Record eviction metrics
218            let evicted_count = initial_len - self.experiments.len();
219            metrics::counter!("vex_evolution_evictions_total").increment(evicted_count as u64);
220        }
221
222        metrics::gauge!("vex_evolution_memory_size").set(self.experiments.len() as f64);
223    }
224
225    /// Apply temporal decay to all experiments (call periodically)
226    pub fn apply_decay(&mut self, decay_factor: f64) {
227        for (_, importance) in &mut self.experiments {
228            *importance *= decay_factor;
229        }
230    }
231}
232
233/// Suggested trait adjustment based on learned correlations
234#[derive(Debug, Clone)]
235pub struct TraitAdjustment {
236    /// Name of the trait
237    pub trait_name: String,
238    /// Current trait value
239    pub current_value: f64,
240    /// Suggested new value
241    pub suggested_value: f64,
242    /// Correlation coefficient (-1.0 to 1.0)
243    pub correlation: f64,
244    /// Confidence in this suggestion (0.0 to 1.0)
245    pub confidence: f64,
246}
247
248/// Calculate Pearson correlation coefficient
249///
250/// # Security
251/// Validates inputs for NaN/Infinity and checks for numeric overflow
252/// to prevent silent corruption of correlations.
253fn pearson_correlation(x: &[f64], y: &[f64]) -> f64 {
254    if x.len() != y.len() || x.is_empty() {
255        return 0.0;
256    }
257
258    // Validate all inputs are finite (no NaN/Infinity)
259    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
260        return 0.0;
261    }
262
263    let n = x.len() as f64;
264    let sum_x: f64 = x.iter().sum();
265    let sum_y: f64 = y.iter().sum();
266    let sum_xy: f64 = x.iter().zip(y).map(|(a, b)| a * b).sum();
267    let sum_x2: f64 = x.iter().map(|a| a * a).sum();
268    let sum_y2: f64 = y.iter().map(|b| b * b).sum();
269
270    // Check for overflow in intermediate calculations
271    if !sum_xy.is_finite() || !sum_x2.is_finite() || !sum_y2.is_finite() {
272        return 0.0;
273    }
274
275    let numerator = n * sum_xy - sum_x * sum_y;
276    let denominator = ((n * sum_x2 - sum_x.powi(2)) * (n * sum_y2 - sum_y.powi(2))).sqrt();
277
278    // Check for near-zero denominator (prevent division by zero)
279    if denominator.abs() < 1e-10 || !numerator.is_finite() || !denominator.is_finite() {
280        return 0.0;
281    }
282
283    let result = numerator / denominator;
284
285    // Final validation: ensure result is finite
286    if !result.is_finite() {
287        0.0
288    } else {
289        result.clamp(-1.0, 1.0)
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_evolution_memory_basic() {
299        let mut memory = EvolutionMemory::new();
300
301        let genome = Genome::new("Test");
302        let exp = GenomeExperiment::new(&genome, HashMap::new(), 0.8, "Task 1");
303        memory.record(exp);
304
305        assert_eq!(memory.len(), 1);
306        assert!(!memory.is_empty());
307    }
308
309    #[test]
310    fn test_pearson_correlation() {
311        // Perfect positive correlation
312        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
313        let y = vec![1.0, 2.0, 3.0, 4.0, 5.0];
314        let corr = pearson_correlation(&x, &y);
315        assert!((corr - 1.0).abs() < 0.001, "Expected ~1.0, got {}", corr);
316
317        // Perfect negative correlation
318        let y_neg = vec![5.0, 4.0, 3.0, 2.0, 1.0];
319        let corr_neg = pearson_correlation(&x, &y_neg);
320        assert!(
321            (corr_neg + 1.0).abs() < 0.001,
322            "Expected ~-1.0, got {}",
323            corr_neg
324        );
325
326        // No correlation (random)
327        let y_rand = vec![3.0, 1.0, 4.0, 2.0, 5.0];
328        let corr_rand = pearson_correlation(&x, &y_rand);
329        assert!(
330            corr_rand.abs() < 0.8,
331            "Expected low correlation, got {}",
332            corr_rand
333        );
334    }
335
336    #[test]
337    fn test_correlation_learning() {
338        let mut memory = EvolutionMemory::with_capacity(100);
339
340        // Add experiments with positive correlation between exploration and fitness
341        for i in 0..20 {
342            let exploration = 0.3 + (i as f64 * 0.03);
343            let fitness = 0.4 + (i as f64 * 0.02);
344
345            let exp = GenomeExperiment::from_raw(
346                vec![exploration, 0.5, 0.5, 0.5, 0.5],
347                vec![
348                    "exploration".into(),
349                    "precision".into(),
350                    "creativity".into(),
351                    "skepticism".into(),
352                    "verbosity".into(),
353                ],
354                fitness,
355                "test task",
356            );
357            memory.record(exp);
358        }
359
360        // Force correlation update
361        memory.update_correlations();
362
363        // Check correlation was learned
364        let corr = memory
365            .correlations()
366            .get("exploration")
367            .copied()
368            .unwrap_or(0.0);
369        assert!(corr > 0.5, "Expected positive correlation, got {}", corr);
370    }
371
372    #[test]
373    fn test_eviction() {
374        let mut memory = EvolutionMemory::with_capacity(5);
375
376        let genome = Genome::new("Test");
377        for i in 0..10 {
378            let exp = GenomeExperiment::new(
379                &genome,
380                HashMap::new(),
381                i as f64 / 10.0,
382                &format!("Task {}", i),
383            );
384            memory.record(exp);
385        }
386
387        assert_eq!(memory.len(), 5);
388    }
389
390    #[test]
391    fn test_suggest_adjustments() {
392        let mut memory = EvolutionMemory::new();
393
394        // Add experiments showing high exploration = high fitness
395        for i in 0..15 {
396            let exploration = 0.3 + (i as f64 * 0.04);
397            let fitness = 0.4 + (i as f64 * 0.03);
398
399            let exp = GenomeExperiment::from_raw(
400                vec![exploration, 0.5, 0.5, 0.5, 0.5],
401                vec![
402                    "exploration".into(),
403                    "precision".into(),
404                    "creativity".into(),
405                    "skepticism".into(),
406                    "verbosity".into(),
407                ],
408                fitness,
409                "test",
410            );
411            memory.record(exp);
412        }
413
414        memory.update_correlations();
415
416        let genome = Genome::new("Current");
417        let suggestions = memory.suggest_adjustments(&genome);
418
419        // Should suggest increasing exploration
420        let exp_suggestion = suggestions.iter().find(|s| s.trait_name == "exploration");
421        assert!(
422            exp_suggestion.is_some(),
423            "Should suggest exploration adjustment"
424        );
425        if let Some(s) = exp_suggestion {
426            assert!(
427                s.suggested_value > s.current_value,
428                "Should suggest increasing exploration"
429            );
430        }
431    }
432
433    // === SECURITY TESTS ===
434
435    #[test]
436    fn test_dos_memory_bounded() {
437        let mut memory = EvolutionMemory::new();
438
439        // Spam 10k low-fitness experiments (DoS attack simulation)
440        for i in 0..10_000 {
441            let exp = GenomeExperiment::from_raw(
442                vec![0.5; 5],
443                vec![
444                    "t1".into(),
445                    "t2".into(),
446                    "t3".into(),
447                    "t4".into(),
448                    "t5".into(),
449                ],
450                0.1, // Low fitness (should be evicted)
451                &format!("spam_{}", i),
452            );
453            memory.record(exp);
454        }
455
456        // Should cap at max_entries (500)
457        assert!(
458            memory.len() <= 500,
459            "Memory grew unbounded: {} entries",
460            memory.len()
461        );
462
463        // High-fitness experiments should be kept
464        let top = memory.get_top_experiments(10);
465        assert!(
466            top.iter().all(|e| e.overall_fitness >= 0.1),
467            "Lost high-fitness experiments"
468        );
469    }
470
471    #[test]
472    fn test_pearson_nan_safety() {
473        // NaN input
474        let x = vec![f64::NAN, 1.0, 2.0];
475        let y = vec![1.0, 2.0, 3.0];
476        let result = pearson_correlation(&x, &y);
477        assert!(result.is_finite(), "Must handle NaN input, got {}", result);
478        assert_eq!(result, 0.0);
479
480        // Infinity input
481        let x_inf = vec![f64::INFINITY, 1.0, 2.0];
482        let result_inf = pearson_correlation(&x_inf, &y);
483        assert!(result_inf.is_finite(), "Must handle Infinity input");
484        assert_eq!(result_inf, 0.0);
485
486        // Extreme values that might overflow
487        let x_big = vec![f64::MAX / 2.0; 5];
488        let y_big = vec![f64::MAX / 2.0; 5];
489        let result_big = pearson_correlation(&x_big, &y_big);
490        assert!(result_big.is_finite(), "Must handle overflow");
491    }
492
493    #[test]
494    fn test_capacity_limit() {
495        // Try to create with absurdly high capacity (DoS)
496        let memory = EvolutionMemory::with_capacity(1_000_000);
497
498        // Should be capped at 10,000
499        assert!(
500            memory.max_entries <= 10_000,
501            "Capacity not capped: {}",
502            memory.max_entries
503        );
504    }
505}