Skip to main content

vex_core/
evolution.rs

1//! Evolutionary operators for VEX agents
2//!
3//! Provides genetic algorithm primitives for agent evolution:
4//! - Genome representation
5//! - Crossover operators
6//! - Mutation operators
7//! - Fitness evaluation
8
9use rand::Rng;
10use serde::{Deserialize, Serialize};
11
12/// A fitness score (higher is better)
13#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
14pub struct Fitness(pub f64);
15
16impl Fitness {
17    /// Create a new fitness score
18    pub fn new(value: f64) -> Self {
19        Self(value.clamp(0.0, 1.0))
20    }
21
22    /// Perfect fitness (1.0)
23    pub fn perfect() -> Self {
24        Self(1.0)
25    }
26
27    /// Zero fitness
28    pub fn zero() -> Self {
29        Self(0.0)
30    }
31
32    /// Get the raw value
33    pub fn value(&self) -> f64 {
34        self.0
35    }
36}
37
38/// A genome representing agent strategy/behavior
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Genome {
41    /// System prompt/role (the "DNA")
42    pub prompt: String,
43    /// Strategy parameters (0.0 - 1.0 each)
44    pub traits: Vec<f64>,
45    /// Labels for each trait
46    pub trait_names: Vec<String>,
47}
48
49impl Genome {
50    /// Create a new genome with default traits
51    pub fn new(prompt: &str) -> Self {
52        Self {
53            prompt: prompt.to_string(),
54            traits: vec![0.5; 5], // Default 5 traits at 0.5
55            trait_names: vec![
56                "exploration".to_string(),
57                "precision".to_string(),
58                "creativity".to_string(),
59                "skepticism".to_string(),
60                "verbosity".to_string(),
61            ],
62        }
63    }
64
65    /// Create genome with custom traits
66    pub fn with_traits(prompt: &str, traits: Vec<(String, f64)>) -> Self {
67        let (names, values): (Vec<_>, Vec<_>) = traits.into_iter().unzip();
68        Self {
69            prompt: prompt.to_string(),
70            traits: values,
71            trait_names: names,
72        }
73    }
74
75    /// Get a named trait value
76    pub fn get_trait(&self, name: &str) -> Option<f64> {
77        self.trait_names
78            .iter()
79            .position(|n| n == name)
80            .map(|i| self.traits[i])
81    }
82
83    /// Set a named trait value
84    pub fn set_trait(&mut self, name: &str, value: f64) {
85        if let Some(i) = self.trait_names.iter().position(|n| n == name) {
86            self.traits[i] = value.clamp(0.0, 1.0);
87        }
88    }
89
90    /// Convert genome traits to LLM parameters using default mapping config.
91    pub fn to_llm_params(&self) -> LlmParams {
92        self.to_llm_params_with_config(&TraitMappingConfig::default())
93    }
94
95    /// Convert genome traits to LLM parameters with custom mapping config.
96    /// Maps:
97    /// - exploration → temperature (configurable range)
98    /// - precision → top_p (1.0 - precision * reduction_factor)
99    /// - creativity → presence_penalty (0.0-1.0 direct)
100    /// - skepticism → frequency_penalty (0.0 - configurable max)
101    /// - verbosity → max_tokens scaling (configurable range)
102    pub fn to_llm_params_with_config(&self, config: &TraitMappingConfig) -> LlmParams {
103        let exploration = self.get_trait("exploration").unwrap_or(0.5);
104        let precision = self.get_trait("precision").unwrap_or(0.5);
105        let creativity = self.get_trait("creativity").unwrap_or(0.5);
106        let skepticism = self.get_trait("skepticism").unwrap_or(0.5);
107        let verbosity = self.get_trait("verbosity").unwrap_or(0.5);
108
109        let (temp_min, temp_max) = config.temperature_range;
110        let (tok_min, tok_max) = config.max_tokens_range;
111
112        LlmParams {
113            temperature: temp_min + exploration * (temp_max - temp_min),
114            top_p: 1.0 - (precision * config.top_p_reduction),
115            presence_penalty: creativity,
116            frequency_penalty: skepticism * config.frequency_penalty_max,
117            max_tokens_multiplier: tok_min + verbosity * (tok_max - tok_min),
118        }
119    }
120}
121
122/// Configuration for mapping genome traits [0,1] to LLM parameter ranges.
123/// Each field is (min, max) defining the output range.
124#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
125pub struct TraitMappingConfig {
126    /// exploration [0,1] → temperature [min, max] (default: 0.1..1.5)
127    pub temperature_range: (f64, f64),
128    /// precision [0,1] → top_p reduction factor (default: 0.5, meaning top_p = 1.0 - precision * factor)
129    pub top_p_reduction: f64,
130    /// skepticism [0,1] → frequency_penalty [0, max] (default: max=0.5)
131    pub frequency_penalty_max: f64,
132    /// verbosity [0,1] → max_tokens_multiplier [min, max] (default: 0.5..2.0)
133    pub max_tokens_range: (f64, f64),
134}
135
136impl Default for TraitMappingConfig {
137    fn default() -> Self {
138        Self {
139            temperature_range: (0.1, 1.5),
140            top_p_reduction: 0.5,
141            frequency_penalty_max: 0.5,
142            max_tokens_range: (0.5, 2.0),
143        }
144    }
145}
146
147/// LLM inference parameters derived from genome traits
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
149pub struct LlmParams {
150    /// Controls randomness (0.0 = deterministic, 2.0 = very random)
151    pub temperature: f64,
152    /// Nucleus sampling threshold (1.0 = all tokens, 0.5 = top 50% probability mass)
153    pub top_p: f64,
154    /// Penalty for tokens already in context (-2.0 to 2.0)
155    pub presence_penalty: f64,
156    /// Penalty for token frequency (-2.0 to 2.0)
157    pub frequency_penalty: f64,
158    /// Multiplier for max_tokens (applied to base value)
159    pub max_tokens_multiplier: f64,
160}
161
162impl LlmParams {
163    /// Apply multiplier to a base max_tokens value
164    pub fn max_tokens(&self, base: u32) -> u32 {
165        ((base as f64) * self.max_tokens_multiplier) as u32
166    }
167
168    /// Default conservative params (low temperature, high precision)
169    pub fn conservative() -> Self {
170        Self {
171            temperature: 0.3,
172            top_p: 0.9,
173            presence_penalty: 0.0,
174            frequency_penalty: 0.1,
175            max_tokens_multiplier: 1.0,
176        }
177    }
178
179    /// Creative params (higher temperature, lower precision)
180    pub fn creative() -> Self {
181        Self {
182            temperature: 1.2,
183            top_p: 0.95,
184            presence_penalty: 0.5,
185            frequency_penalty: 0.0,
186            max_tokens_multiplier: 1.5,
187        }
188    }
189}
190
191/// Trait for genetic operators
192pub trait GeneticOperator {
193    /// Perform crossover between two parent genomes
194    fn crossover(&self, parent_a: &Genome, parent_b: &Genome) -> Genome;
195
196    /// Mutate a genome with given mutation rate
197    fn mutate(&self, genome: &mut Genome, mutation_rate: f64);
198}
199
200/// Standard genetic operator implementation
201#[derive(Debug, Clone, Default)]
202pub struct StandardOperator;
203
204impl GeneticOperator for StandardOperator {
205    fn crossover(&self, parent_a: &Genome, parent_b: &Genome) -> Genome {
206        let mut rng = rand::thread_rng();
207
208        // Single-point crossover for traits
209        let crossover_point = rng.gen_range(0..parent_a.traits.len());
210        let mut child_traits = Vec::with_capacity(parent_a.traits.len());
211
212        for i in 0..parent_a.traits.len() {
213            if i < crossover_point {
214                child_traits.push(parent_a.traits[i]);
215            } else {
216                child_traits.push(parent_b.traits[i]);
217            }
218        }
219
220        // Randomly pick one parent's prompt (or could combine them)
221        let prompt = if rng.gen_bool(0.5) {
222            parent_a.prompt.clone()
223        } else {
224            parent_b.prompt.clone()
225        };
226
227        Genome {
228            prompt,
229            traits: child_traits,
230            trait_names: parent_a.trait_names.clone(),
231        }
232    }
233
234    fn mutate(&self, genome: &mut Genome, mutation_rate: f64) {
235        let mut rng = rand::thread_rng();
236
237        for trait_val in &mut genome.traits {
238            if rng.gen_bool(mutation_rate) {
239                // Gaussian mutation
240                let delta: f64 = rng.gen_range(-0.2..0.2);
241                *trait_val = (*trait_val + delta).clamp(0.0, 1.0);
242            }
243        }
244    }
245}
246
247/// Select parents from a population based on fitness (tournament selection)
248pub fn tournament_select(population: &[(Genome, Fitness)], tournament_size: usize) -> &Genome {
249    let mut rng = rand::thread_rng();
250    let mut best: Option<&(Genome, Fitness)> = None;
251
252    for _ in 0..tournament_size {
253        let idx = rng.gen_range(0..population.len());
254        let candidate = &population[idx];
255
256        if best.is_none() || candidate.1 > best.unwrap().1 {
257            best = Some(candidate);
258        }
259    }
260
261    &best.unwrap().0
262}
263
264impl StandardOperator {
265    /// Produce a next generation using parallel evolution
266    pub fn produce_next_generation(
267        &self,
268        population: &[(Genome, Fitness)],
269        size: usize,
270        mutation_rate: f64,
271        tournament_size: usize,
272    ) -> Vec<Genome> {
273        use rayon::prelude::*;
274
275        (0..size)
276            .into_par_iter()
277            .map(|_| {
278                let parent_a = tournament_select(population, tournament_size);
279                let parent_b = tournament_select(population, tournament_size);
280
281                let mut child = self.crossover(parent_a, parent_b);
282                self.mutate(&mut child, mutation_rate);
283                child
284            })
285            .collect()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_genome_traits() {
295        let mut genome = Genome::new("Test agent");
296        assert_eq!(genome.get_trait("exploration"), Some(0.5));
297
298        genome.set_trait("exploration", 0.8);
299        assert_eq!(genome.get_trait("exploration"), Some(0.8));
300    }
301
302    #[test]
303    fn test_crossover() {
304        let parent_a =
305            Genome::with_traits("A", vec![("a".to_string(), 0.0), ("b".to_string(), 0.0)]);
306        let parent_b =
307            Genome::with_traits("B", vec![("a".to_string(), 1.0), ("b".to_string(), 1.0)]);
308
309        let operator = StandardOperator;
310        let child = operator.crossover(&parent_a, &parent_b);
311
312        // Child should have traits from both parents
313        assert!(child.traits.iter().all(|&t| t == 0.0 || t == 1.0));
314    }
315
316    #[test]
317    fn test_mutation_preserves_bounds() {
318        let operator = StandardOperator;
319        for _ in 0..100 {
320            let mut genome = Genome::new("Test");
321            operator.mutate(&mut genome, 1.0);
322            for &t in &genome.traits {
323                assert!((0.0..=1.0).contains(&t), "Trait {} out of bounds", t);
324            }
325        }
326    }
327
328    #[test]
329    fn test_crossover_identical_parents() {
330        let parent = Genome::with_traits("P", vec![("a".to_string(), 0.5), ("b".to_string(), 0.7)]);
331        let operator = StandardOperator;
332        let child = operator.crossover(&parent, &parent);
333        // Child should have same trait values when parents are identical
334        assert_eq!(child.traits, parent.traits);
335    }
336
337    #[test]
338    fn test_llm_params_defaults() {
339        let genome = Genome::new("Test");
340        let params = genome.to_llm_params();
341        // With all traits at 0.5, check reasonable ranges
342        assert!(params.temperature > 0.0 && params.temperature < 2.0);
343        assert!(params.top_p > 0.0 && params.top_p <= 1.0);
344        assert!(params.presence_penalty >= 0.0 && params.presence_penalty <= 1.0);
345        assert!(params.frequency_penalty >= 0.0);
346        assert!(params.max_tokens_multiplier > 0.0);
347    }
348
349    #[test]
350    fn test_mutation() {
351        let mut genome = Genome::new("Test");
352        let original_traits = genome.traits.clone();
353
354        let operator = StandardOperator;
355        operator.mutate(&mut genome, 1.0); // 100% mutation rate
356
357        // At least some traits should have changed
358        assert!(genome
359            .traits
360            .iter()
361            .zip(original_traits.iter())
362            .any(|(a, b)| a != b));
363    }
364}