| name | lgp-experiment |
| description | Run Linear Genetic Programming experiments — train agents on CartPole, MountainCar, or Iris classification with optional Q-Learning and hyperparameter optimization. Use when running LGP experiments, tuning hyperparameters, analyzing results, or extending the framework with new problem domains. |
| metadata | {"argument-hint":["experiment-name"]} |
LGP Experiment Runner
Run and manage LGP experiments.
Quick Start
lgp list
lgp run cart_pole_lgp
lgp run cart_pole_lgp --config optimal
Available Experiments
| Experiment | Description |
|---|
iris_baseline | Iris baseline (no mutation, no crossover) |
iris_mutation | Iris with mutation only |
iris_crossover | Iris with crossover only |
iris_full | Iris full (mutation + crossover) |
cart_pole_lgp | CartPole with pure LGP |
cart_pole_with_q | CartPole with Q-Learning |
mountain_car_lgp | MountainCar with pure LGP |
mountain_car_with_q | MountainCar with Q-Learning |
Full Pipeline
lgp experiment cart_pole_lgp
lgp search cart_pole_lgp
lgp analyze
Logging
RUST_LOG=lgp=debug lgp run iris_baseline
RUST_LOG=lgp=trace lgp run iris_baseline
Output Structure
Results are written to outputs/ with timestamped runs containing config, best/median/worst individuals, and population history.
Extending the LGP Framework
This section explains how to add new problem domains, custom genetic operators, and alternative fitness functions to the Linear Genetic Programming framework.
Table of Contents
- Extension Quick Start
- Architecture Overview
- Core Traits
- Adding a Classification Problem
- Adding an RL Problem
- Custom Genetic Operators
- Testing Extensions
Extension Quick Start
Here's a minimal example - a classifier that predicts if a number is positive:
use lgp::core::{environment::State, engines::*};
pub struct SignState {
values: Vec<(f64, usize)>,
idx: usize,
}
impl State for SignState {
fn get_value(&self, _: usize) -> f64 { self.values[self.idx].0 }
fn execute_action(&mut self, action: usize) -> f64 {
let correct = action == self.values[self.idx].1;
self.idx += 1;
if correct { 1.0 } else { 0.0 }
}
fn get(&mut self) -> Option<&mut Self> {
if self.idx < self.values.len() { Some(self) } else { None }
}
}
impl Reset<SignState> for ResetEngine {
fn reset(s: &mut SignState) { s.idx = 0; }
}
impl Generate<(), SignState> for GenerateEngine {
fn generate(_: ()) -> SignState {
SignState {
values: vec![(-1.0, 0), (1.0, 1), (-5.0, 0), (3.0, 1)],
idx: 0,
}
}
}
pub struct SignEngine;
impl Core for SignEngine {
type State = SignState;
type Individual = Program;
type ProgramParameters = ProgramGeneratorParameters;
type FitnessMarker = ();
type Generate = GenerateEngine;
type Fitness = FitnessEngine;
type Reset = ResetEngine;
type Breed = BreedEngine;
type Mutate = MutateEngine;
type Status = StatusEngine;
type Freeze = FreezeEngine;
}
That's it! The framework handles evolution, selection, and mutation automatically.
For a complete walkthrough including CLI integration, see Adding a Classification Problem.
Architecture Overview
The framework uses a trait-based plugin architecture. Each problem domain implements the Core trait, which ties together all the genetic algorithm components:
┌─────────────────────────────────────────────────────────────┐
│ Core Trait │
├─────────────────────────────────────────────────────────────┤
│ Individual - The evolving entity (Program, QProgram) │
│ State - Environment/dataset representation │
│ FitnessMarker - Selects fitness evaluation strategy │
├─────────────────────────────────────────────────────────────┤
│ Generate - Creates new individuals and states │
│ Fitness - Evaluates individual performance │
│ Reset - Resets individuals and states │
│ Breed - Crossover operations │
│ Mutate - Mutation operations │
│ Status - Fitness management │
│ Freeze - Optional freezing behavior │
└─────────────────────────────────────────────────────────────┘
The genetic algorithm loop:
- Initialize - Generate initial population
- Evaluate - Run fitness evaluation on trials
- Rank - Sort population by fitness (descending)
- Survive - Select survivors based on gap parameter
- Variation - Create offspring via mutation, crossover, cloning
- Repeat from step 2
Core Traits
State Trait
The State trait represents the environment or dataset that programs are evaluated against:
pub trait State: Sized {
fn get_value(&self, at_idx: usize) -> f64;
fn execute_action(&mut self, action: usize) -> f64;
fn get(&mut self) -> Option<&mut Self>;
}
For RL problems, extend with RlState:
pub trait RlState: State {
fn is_terminal(&mut self) -> bool;
fn get_initial_state(&self) -> Vec<f64>;
}
Core Trait
The Core trait defines all components for a problem domain:
pub trait Core {
type Individual: Ord + Clone + Send + Sync + Serialize + DeserializeOwned;
type ProgramParameters: Copy + Send + Sync + Clone + Serialize + DeserializeOwned + Args;
type State: State;
type FitnessMarker;
type Generate: Generate<Self::ProgramParameters, Self::Individual> + Generate<(), Self::State>;
type Fitness: Fitness<Self::Individual, Self::State, Self::FitnessMarker>;
type Reset: Reset<Self::Individual> + Reset<Self::State>;
type Breed: Breed<Self::Individual>;
type Mutate: Mutate<Self::ProgramParameters, Self::Individual>;
type Status: Status<Self::Individual>;
type Freeze: Freeze<Self::Individual>;
}
Adding a Classification Problem
Let's walk through adding a complete XOR classification problem.
Step 1: Define the State
use serde::{Deserialize, Serialize};
use crate::core::environment::State;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct XorInput {
pub x1: f64,
pub x2: f64,
pub expected: usize,
}
pub struct XorState {
data: Vec<XorInput>,
idx: usize,
}
impl State for XorState {
fn get_value(&self, idx: usize) -> f64 {
let item = &self.data[self.idx];
match idx {
0 => item.x1,
1 => item.x2,
_ => panic!("XOR only has 2 inputs"),
}
}
fn execute_action(&mut self, action: usize) -> f64 {
let item = &self.data[self.idx];
self.idx += 1;
if action == item.expected { 1.0 } else { 0.0 }
}
fn get(&mut self) -> Option<&mut Self> {
if self.idx >= self.data.len() {
return None;
}
Some(self)
}
}
Step 2: Implement Reset and Generate
use crate::core::engines::reset_engine::{Reset, ResetEngine};
use crate::core::engines::generate_engine::{Generate, GenerateEngine};
use crate::utils::random::generator;
use rand::seq::SliceRandom;
impl Reset<XorState> for ResetEngine {
fn reset(item: &mut XorState) {
item.idx = 0;
}
}
impl Generate<(), XorState> for GenerateEngine {
fn generate(_using: ()) -> XorState {
let mut data = vec![
XorInput { x1: 0.0, x2: 0.0, expected: 0 },
XorInput { x1: 0.0, x2: 1.0, expected: 1 },
XorInput { x1: 1.0, x2: 0.0, expected: 1 },
XorInput { x1: 1.0, x2: 1.0, expected: 0 },
];
data.shuffle(&mut generator());
XorState { data, idx: 0 }
}
}
Step 3: Define the Core Implementation
use crate::core::engines::{
core_engine::Core,
breed_engine::BreedEngine,
fitness_engine::FitnessEngine,
freeze_engine::FreezeEngine,
mutate_engine::MutateEngine,
status_engine::StatusEngine,
};
use crate::core::program::{Program, ProgramGeneratorParameters};
#[derive(Clone)]
pub struct XorEngine;
impl Core for XorEngine {
type State = XorState;
type Individual = Program;
type ProgramParameters = ProgramGeneratorParameters;
type FitnessMarker = ();
type Generate = GenerateEngine;
type Fitness = FitnessEngine;
type Reset = ResetEngine;
type Breed = BreedEngine;
type Mutate = MutateEngine;
type Status = StatusEngine;
type Freeze = FreezeEngine;
}
Step 4: Register as an Experiment
The config system uses TOML files in the configs/ directory. To add a new experiment:
-
Create config directory and default.toml:
mkdir -p configs/xor_lgp
[experiment]
name = "xor_lgp"
environment = "xor"
[hyperparameters]
population_size = 50
n_generations = 100
n_trials = 10
gap = 0.5
[hyperparameters.program]
max_instructions = 20
n_inputs = 2
n_actions = 2
[operations]
mutation_rate = 0.5
crossover_rate = 0.5
-
Implement the State trait and Core trait in crates/lgp/src/problems/xor.rs
-
Register the environment in experiment_runner.rs:
Add a match arm in crates/lgp-cli/src/experiment_runner.rs:
match (config.environment.as_str(), config.has_q_learning()) {
("Xor" | "xor", _) => run_xor(config, seed, &output)?,
_ => return Err(format!("Unknown environment: {}", config.environment).into()),
}
-
Implement the run function following existing patterns (e.g., run_iris):
fn run_xor(
config: &ExperimentConfig,
seed: u64,
output: &ExperimentOutput,
) -> Result<(), Box<dyn std::error::Error>> {
let parameters: HyperParameters<XorEngine> = HyperParameters {
default_fitness: config.hyperparameters.default_fitness,
population_size: config.hyperparameters.population_size,
gap: config.hyperparameters.gap,
mutation_percent: config.mutation_percent(),
crossover_percent: config.crossover_percent(),
n_generations: config.hyperparameters.n_generations,
n_trials: config.hyperparameters.n_trials,
seed: Some(seed),
program_parameters: build_program_params(config),
};
run_and_save::<XorEngine>(¶meters, output)
}
Step 5: Add Module Export
In crates/lgp/src/problems/mod.rs:
pub mod gym;
pub mod iris;
pub mod xor;
Adding an RL Problem
For RL problems, use the gym-rs adapter pattern or implement RlState directly.
Using gym-rs
If your environment implements the gym_rs::core::Env trait, you can use the existing GymRsEngine adapter. Registration is done in crates/lgp-cli/src/experiment_runner.rs:
-
Create TOML config:
[experiment]
name = "your_env_lgp"
environment = "your_env"
[problem]
n_inputs = 4
n_actions = 2
[hyperparameters]
population_size = 100
n_generations = 500
-
Add match arm in experiment_runner.rs:
use gym_rs::envs::your_module::YourEnv;
match (config.environment.as_str(), config.has_q_learning()) {
("YourEnv" | "your_env", false) => run_your_env_lgp(config, seed, &output)?,
("YourEnv" | "your_env", true) => {
run_your_env_q(config, seed, &output, config.q_learning_params().unwrap())?
}
_ => return Err(format!("Unknown environment: {}", config.environment).into()),
}
-
Implement run functions following the patterns for run_cart_pole_lgp and run_cart_pole_q.
Custom RL Environment
For custom environments not using gym-rs:
use crate::core::environment::{State, RlState};
pub struct CustomRlState {
observation: Vec<f64>,
terminal: bool,
episode_step: usize,
max_steps: usize,
}
impl State for CustomRlState {
fn get_value(&self, idx: usize) -> f64 {
self.observation[idx]
}
fn execute_action(&mut self, action: usize) -> f64 {
let reward = self.step(action);
self.episode_step += 1;
reward
}
fn get(&mut self) -> Option<&mut Self> {
if self.terminal || self.episode_step >= self.max_steps {
return None;
}
Some(self)
}
}
impl RlState for CustomRlState {
fn is_terminal(&mut self) -> bool {
self.terminal || self.episode_step >= self.max_steps
}
fn get_initial_state(&self) -> Vec<f64> {
vec![0.0; self.observation.len()]
}
}
Then use UseRlFitness as the FitnessMarker:
use crate::extensions::interactive::UseRlFitness;
impl Core for CustomRlEngine {
type FitnessMarker = UseRlFitness;
}
Custom Genetic Operators
Custom Mutation Operator
Create a new mutate engine:
use crate::core::engines::mutate_engine::Mutate;
use crate::core::program::{Program, ProgramGeneratorParameters};
pub struct AggressiveMutateEngine;
impl Mutate<ProgramGeneratorParameters, Program> for AggressiveMutateEngine {
fn mutate(item: &mut Program, using: ProgramGeneratorParameters) {
let n_mutations = 3.min(item.instructions.len());
for _ in 0..n_mutations {
}
}
}
Use it in your Core implementation:
impl Core for MyEngine {
type Mutate = AggressiveMutateEngine;
}
Custom Crossover Operator
use crate::core::engines::breed_engine::Breed;
use crate::core::program::Program;
pub struct UniformCrossoverEngine;
impl Breed<Program> for UniformCrossoverEngine {
fn two_point_crossover(mate_1: &Program, mate_2: &Program) -> (Program, Program) {
}
}
Custom Fitness Function
The FitnessMarker type parameter selects which fitness implementation to use. Create a new marker and implement Fitness:
use crate::core::engines::fitness_engine::{Fitness, FitnessEngine};
pub struct WeightedAccuracyFitness;
impl<T: State> Fitness<Program, T, WeightedAccuracyFitness> for FitnessEngine {
fn eval_fitness(program: &mut Program, states: &mut T) -> f64 {
let mut weighted_score = 0.0;
let mut total_weight = 0.0;
while let Some(state) = states.get() {
program.run(state);
let weight = get_class_weight(state);
let correct = ;
weighted_score += correct * weight;
total_weight += weight;
}
weighted_score / total_weight
}
}
Then use it:
impl Core for WeightedEngine {
type FitnessMarker = WeightedAccuracyFitness;
}
Testing Extensions
Unit Test Pattern
#[cfg(test)]
mod tests {
use super::*;
use crate::core::engines::core_engine::HyperParametersBuilder;
use crate::core::instruction::InstructionGeneratorParametersBuilder;
use crate::core::program::ProgramGeneratorParametersBuilder;
use itertools::Itertools;
#[test]
fn test_xor_evolution() {
let instruction_params = InstructionGeneratorParametersBuilder::default()
.n_actions(2)
.n_inputs(2)
.build()
.unwrap();
let program_params = ProgramGeneratorParametersBuilder::default()
.max_instructions(20)
.instruction_generator_parameters(instruction_params)
.build()
.unwrap();
let hyperparams = HyperParametersBuilder::<XorEngine>::default()
.program_parameters(program_params)
.population_size(50)
.n_generations(100)
.n_trials(10)
.build()
.unwrap();
let populations: Vec<_> = hyperparams
.build_engine()
.take(hyperparams.n_generations)
.collect();
let first_gen = &populations[0];
let last_gen = populations.last().unwrap();
let first_best = StatusEngine::get_fitness(first_gen.first().unwrap());
let last_best = StatusEngine::get_fitness(last_gen.first().unwrap());
assert!(last_best >= first_best, "Fitness should improve over generations");
}
}
Integration Test
Create a test file in crates/lgp/tests/:
use lgp::core::engines::core_engine::HyperParameters;
use lgp::problems::xor::XorEngine;
#[test]
fn xor_solves_problem() {
let params = ;
let populations: Vec<_> = params
.build_engine()
.take(params.n_generations)
.collect();
let best = populations.last().unwrap().first().unwrap();
let fitness = StatusEngine::get_fitness(best);
assert!(fitness > 0.9, "Expected >90% accuracy, got {}", fitness);
}
Benchmark Test
Add to crates/lgp/benches/performance_after_training.rs:
fn xor_benchmark(c: &mut Criterion) {
}
criterion_group!(
benches,
xor_benchmark,
);
Extension Summary
To add a new problem:
- Define State - Implement
State (or RlState for RL)
- Implement Reset - Reset state for re-evaluation
- Implement Generate - Create initial state instances
- Define Engine - Implement
Core trait
- Create TOML config - Add
configs/<name>/default.toml with experiment configuration
- Register in experiment_runner - Add match arm in
crates/lgp-cli/src/experiment_runner.rs
- Test - Write unit and integration tests
The framework's trait system allows mixing and matching components. You can use the default BreedEngine, MutateEngine, etc., or create custom implementations for specialized behavior.