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
91    /// Maps:
92    /// - exploration → temperature (0.0-1.0 → 0.1-1.5)
93    /// - precision → top_p (0.0-1.0 → 0.5-1.0, inverted for precision)
94    /// - creativity → presence_penalty (0.0-1.0 → 0.0-1.0)
95    /// - skepticism → frequency_penalty (0.0-1.0 → 0.0-0.5)
96    /// - verbosity → max_tokens scaling (0.0-1.0 → 0.5-2.0x multiplier)
97    pub fn to_llm_params(&self) -> LlmParams {
98        let exploration = self.get_trait("exploration").unwrap_or(0.5);
99        let precision = self.get_trait("precision").unwrap_or(0.5);
100        let creativity = self.get_trait("creativity").unwrap_or(0.5);
101        let skepticism = self.get_trait("skepticism").unwrap_or(0.5);
102        let verbosity = self.get_trait("verbosity").unwrap_or(0.5);
103
104        LlmParams {
105            // Higher exploration = higher temperature (more random)
106            temperature: 0.1 + exploration * 1.4,
107            // Higher precision = lower top_p (more focused on best tokens)
108            top_p: 1.0 - (precision * 0.5),
109            // Creativity adds presence penalty to encourage novel topics
110            presence_penalty: creativity,
111            // Skepticism adds frequency penalty to reduce repetition
112            frequency_penalty: skepticism * 0.5,
113            // Verbosity scales max_tokens (0.5x to 2.0x of base)
114            max_tokens_multiplier: 0.5 + verbosity * 1.5,
115        }
116    }
117}
118
119/// LLM inference parameters derived from genome traits
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
121pub struct LlmParams {
122    /// Controls randomness (0.0 = deterministic, 2.0 = very random)
123    pub temperature: f64,
124    /// Nucleus sampling threshold (1.0 = all tokens, 0.5 = top 50% probability mass)
125    pub top_p: f64,
126    /// Penalty for tokens already in context (-2.0 to 2.0)
127    pub presence_penalty: f64,
128    /// Penalty for token frequency (-2.0 to 2.0)
129    pub frequency_penalty: f64,
130    /// Multiplier for max_tokens (applied to base value)
131    pub max_tokens_multiplier: f64,
132}
133
134impl LlmParams {
135    /// Apply multiplier to a base max_tokens value
136    pub fn max_tokens(&self, base: u32) -> u32 {
137        ((base as f64) * self.max_tokens_multiplier) as u32
138    }
139
140    /// Default conservative params (low temperature, high precision)
141    pub fn conservative() -> Self {
142        Self {
143            temperature: 0.3,
144            top_p: 0.9,
145            presence_penalty: 0.0,
146            frequency_penalty: 0.1,
147            max_tokens_multiplier: 1.0,
148        }
149    }
150
151    /// Creative params (higher temperature, lower precision)
152    pub fn creative() -> Self {
153        Self {
154            temperature: 1.2,
155            top_p: 0.95,
156            presence_penalty: 0.5,
157            frequency_penalty: 0.0,
158            max_tokens_multiplier: 1.5,
159        }
160    }
161}
162
163/// Trait for genetic operators
164pub trait GeneticOperator {
165    /// Perform crossover between two parent genomes
166    fn crossover(&self, parent_a: &Genome, parent_b: &Genome) -> Genome;
167
168    /// Mutate a genome with given mutation rate
169    fn mutate(&self, genome: &mut Genome, mutation_rate: f64);
170}
171
172/// Standard genetic operator implementation
173#[derive(Debug, Clone, Default)]
174pub struct StandardOperator;
175
176impl GeneticOperator for StandardOperator {
177    fn crossover(&self, parent_a: &Genome, parent_b: &Genome) -> Genome {
178        let mut rng = rand::thread_rng();
179
180        // Single-point crossover for traits
181        let crossover_point = rng.gen_range(0..parent_a.traits.len());
182        let mut child_traits = Vec::with_capacity(parent_a.traits.len());
183
184        for i in 0..parent_a.traits.len() {
185            if i < crossover_point {
186                child_traits.push(parent_a.traits[i]);
187            } else {
188                child_traits.push(parent_b.traits[i]);
189            }
190        }
191
192        // Randomly pick one parent's prompt (or could combine them)
193        let prompt = if rng.gen_bool(0.5) {
194            parent_a.prompt.clone()
195        } else {
196            parent_b.prompt.clone()
197        };
198
199        Genome {
200            prompt,
201            traits: child_traits,
202            trait_names: parent_a.trait_names.clone(),
203        }
204    }
205
206    fn mutate(&self, genome: &mut Genome, mutation_rate: f64) {
207        let mut rng = rand::thread_rng();
208
209        for trait_val in &mut genome.traits {
210            if rng.gen_bool(mutation_rate) {
211                // Gaussian mutation
212                let delta: f64 = rng.gen_range(-0.2..0.2);
213                *trait_val = (*trait_val + delta).clamp(0.0, 1.0);
214            }
215        }
216    }
217}
218
219/// Select parents from a population based on fitness (tournament selection)
220pub fn tournament_select(population: &[(Genome, Fitness)], tournament_size: usize) -> &Genome {
221    let mut rng = rand::thread_rng();
222    let mut best: Option<&(Genome, Fitness)> = None;
223
224    for _ in 0..tournament_size {
225        let idx = rng.gen_range(0..population.len());
226        let candidate = &population[idx];
227
228        if best.is_none() || candidate.1 > best.unwrap().1 {
229            best = Some(candidate);
230        }
231    }
232
233    &best.unwrap().0
234}
235
236impl StandardOperator {
237    /// Produce a next generation using parallel evolution
238    pub fn produce_next_generation(
239        &self,
240        population: &[(Genome, Fitness)],
241        size: usize,
242        mutation_rate: f64,
243        tournament_size: usize,
244    ) -> Vec<Genome> {
245        use rayon::prelude::*;
246
247        (0..size)
248            .into_par_iter()
249            .map(|_| {
250                let parent_a = tournament_select(population, tournament_size);
251                let parent_b = tournament_select(population, tournament_size);
252
253                let mut child = self.crossover(parent_a, parent_b);
254                self.mutate(&mut child, mutation_rate);
255                child
256            })
257            .collect()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_genome_traits() {
267        let mut genome = Genome::new("Test agent");
268        assert_eq!(genome.get_trait("exploration"), Some(0.5));
269
270        genome.set_trait("exploration", 0.8);
271        assert_eq!(genome.get_trait("exploration"), Some(0.8));
272    }
273
274    #[test]
275    fn test_crossover() {
276        let parent_a =
277            Genome::with_traits("A", vec![("a".to_string(), 0.0), ("b".to_string(), 0.0)]);
278        let parent_b =
279            Genome::with_traits("B", vec![("a".to_string(), 1.0), ("b".to_string(), 1.0)]);
280
281        let operator = StandardOperator;
282        let child = operator.crossover(&parent_a, &parent_b);
283
284        // Child should have traits from both parents
285        assert!(child.traits.iter().all(|&t| t == 0.0 || t == 1.0));
286    }
287
288    #[test]
289    fn test_mutation() {
290        let mut genome = Genome::new("Test");
291        let original_traits = genome.traits.clone();
292
293        let operator = StandardOperator;
294        operator.mutate(&mut genome, 1.0); // 100% mutation rate
295
296        // At least some traits should have changed
297        assert!(genome
298            .traits
299            .iter()
300            .zip(original_traits.iter())
301            .any(|(a, b)| a != b));
302    }
303}