Code Version: 2.0.0
Evolvable is a Ruby gem that brings genetic algorithms to Ruby objects through simple, flexible APIs. Define genes, implement fitness criteria, and let evolution discover optimal solutions through selection, combination, and mutation.
Perfect for optimization problems, creative content generation, machine learning, and simulating complex systems.
Evolvable is ideal when the solution space is too large or complex for brute-force methods. Instead of hardcoding solutions, you define constraints and let evolution discover optimal configurations over time.
The Evolvable Approach:
- Explore vast solution spaces efficiently without examining every possibility
- Discover novel solutions that might not be obvious to human designers
- Adapt to changing conditions through continuous evolution
- Balance diverse objectives with communities of different populations
- Integrate evolutionary concepts directly into your Ruby object model
- Generate creative content like music, art, and text, not just numerical optimization
Whether you're optimizing parameters, generating creative content, or simulating complex systems, Evolvable provides a natural, object-oriented approach to evolutionary algorithms.
Creative Applications
Evolvable treats creative, object-oriented representations as first-class citizens. The same API that optimizes numeric parameters can evolve music compositions, UI layouts, or game content with equal fluency. Examples include:
- Generative art: Evolve visual compositions based on aesthetic criteria
- Music composition: Create melodies, chord progressions, and rhythms
- Game design: Generate levels, characters, or game mechanics
- Natural language: Evolve text with specific tones, styles, or constraints
- UI/UX design: Discover intuitive layouts and color schemes
- Installation
- Getting Started
- Concepts
- Genes
- Populations
- Evaluation
- Evolution
- Selection
- Combination
- Mutation
- Gene Clusters
- Community
- Serialization
- Documentation
Add gem "evolvable" to your Gemfile and run bundle install or install it yourself with: gem install evolvable
Ruby Compatibility: Evolvable officially supports Ruby 3.0 and higher.
Quick start:
- Include
Evolvablein your Ruby class - Define genes with the macro-style
genemethod - Have the
#fitnessmethod return a numeric value - Initialize a population and evolve it
Example population of "shirts" with various colors, buttons, and collars.
# Step 1 class Shirt include Evolvable # Step 2 gene :color, type: ColorGene # count: 1 default gene :buttons, type: ButtonGene, count: 0..10 # Builds an array of genes that can vary in size gene :collar, type: CollarGene, count: 0..1 # Collar optional # Step 3 attr_accessor :fitness end # Step 4 population = Shirt.new_population(size: 10) population.evolvables.each { |shirt| shirt.fitness = style_rating }
You are free to tailor the genes to your needs and find a style that suits you.
The ColorGene could be as simple as this:
class ColorGene include Evolvable::Gene def to_s @to_s ||= %w[red green blue].sample end end
Shirts aren't your style?
Here's a Hello World command line demo.
Evolvable is built on these core concepts:
- Genes: Ruby objects that represent traits or behaviors and are passed down during evolution.
- Evolvables: Your Ruby classes that include "Evolvable" and delegate to genes
- Populations: Groups of evolvables instances that evolve together
- Evaluation: Sorts evolvables by fitness
- Evolution: Selection → Combination → Mutation to generate new evolvables
- Communities: Encapsulate evolvable populations
The framework offers built-in implementations while allowing domain-specific customization through its extensible and swapable components.
Genes are the building blocks of evolvable objects, encapsulating individual characteristics that can be combined and mutated during evolution. Each gene represents a trait or behavior that can influence an evolvable's performance.
To define a gene class:
- Include the
Evolvable::Genemodule - Define how the gene's value is determined
class BehaviorGene include Evolvable::Gene def value @value ||= %w[explore gather attack defend build].sample end end
Then use it in an evolvable class:
class Robot include Evolvable gene :behaviors, type: BehaviorGene, count: 3..5 gene :speed, type: SpeedGene, count: 1 def fitness run_simulation(behaviors: behaviors.map(&:value), speed: speed.value) end end
Gene Count
You can control how many copies of a gene are created using the count: parameter:
count: 1(default) creates a single instance.- A numeric value (e.g.
count: 5) creates a fixed number of genes usingRigidCountGene. - A range (e.g.
count: 2..8) creates a variable number of genes usingCountGene, allowing the count to evolve over time.
Evolves melody length:
gene :notes, type: NoteGene, count: 4..12
Custom Combination
By default, the combine method randomly picks one of the two parent genes.
A gene class can implement custom behavior by overriding .combine.
class SpeedGene include Evolvable::Gene def self.combine(gene_a, gene_b) new_gene = new new_gene.value = (gene_a.value + gene_b.value) / 2 new_gene end attr_writer :value def value @value ||= rand(1..100) end end
Design Patterns
Effective gene design typically follows these principles:
- Immutability: Cache values after initial sampling (e.g.,
@value ||= ...) - Self-Contained: Genes should encapsulate their logic and state
- Composable: You can build complex structures using multiple genes or clusters
- Domain-Specific: Genes should map directly to your problem’s traits or features
Genes come in various types, each representing different aspects of a solution. Common examples include numeric genes for quantities, selection genes for choices from sets, boolean genes for binary decisions, structural genes for architecture, and parameter genes for configuration settings.
Populations orchestrate the evolutionary process through four key components:
- Evaluation: Sorts evolvable instances by fitness
- Selection: Chooses parents for combination
- Combination: Creates new evolvables from selected parents
- Mutation: Introduces variation to maintain genetic diversity
Features:
Initialize a population with default or custom parameters:
population = YourEvolvable.new_population( size: 50, evaluation: { equalize: 0 }, selection: { size: 10 }, mutation: { probability: 0.2, rate: 0.02 } )
Or inject fully customized strategy objects:
population = YourEvolvable.new_population( evaluation: Your::Evaluation.new, evolution: Your::Evolution.new, selection: Your::Selection.new, combination: Your::Combination.new, mutation: Your::Mutation.new )
Evolve your population:
population.evolve(count: 20) # Run for 20 generations population.evolve_to_goal # Run until the current goal is met population.evolve_to_goal(0.0) # Run until a specific goal is met population.evolve_forever # Run indefinitely, ignoring any goal population.evolve_selected([...]) # Use a custom subset of evolvables
Create new evolvables:
new = population.new_evolvable many = population.new_evolvables(count: 10) with_genome = population.new_evolvable(genome: another.genome)
Customize the evolution lifecycle by implementing hooks:
def self.before_evaluation(pop); end def self.before_evolution(pop); end def self.after_evolution(pop); end
Evaluate progress:
best = population.best_evolvable if population.met_goal?
Evaluation sorts evolvables based on their fitness and provides mechanisms to change the goal type and value (fitness goal). Goals define the success criteria for evolution. They allow you to specify what your population is evolving toward, whether it's maximizing a value, minimizing a value, or seeking a specific value.
How It Works
-
Your evolvable class defines a
#fitnessmethod that returns a Comparable object.- Preferably a numeric value like an integer or float.
-
During evolution, evolvables are sorted by your goal's fitness interpretation
- The default goal type is
:maximize, see goal types below for other options
- The default goal type is
-
If a goal value is specified, evolution will stop when it is met
Goal Types
- Maximize (higher is better)
robots = Robot.new_population(evaluation: :maximize) # Defaults to infinity robots.evolve_to_goal(100) # Evolve until fitness reaches 100+ # Same as above Robot.new_population(evaluation: { maximize: 100 }).evolve_to_goal
- Minimize (lower is better)
errors = ErrorModel.new_population(evaluation: :minimize) # Defaults to -infinity errors.evolve_to_goal(0.01) # Evolve until error rate reaches 0.01 or less # Same as above ErrorModel.new_population(evaluation: { minimize: 0.01 }).evolve_to_goal
- Equalize (closer to target is better)
targets = TargetMatcher.new_population(evaluation: :equalize) # Defaults to 0 targets.evolve_to_goal(42) # Evolve until we match the target value # Same as above TargetMatcher.new_population(evaluation: { equalize: 42 }).evolve_to_goal
Custom Goals
You can create custom goals by subclassing Evolvable::Goal and implementing:
evaluate(evolvable): Return a value that for sorting evolvablesmet?(evolvable): Returns true when the goal value is reached
Example goal implementation that prioritizes evolvables with fitness values within a specific range:
class YourRangeGoal < Evolvable::Goal def value @value ||= 0..100 end def evaluate(evolvable) return 1 if value.include?(evolvable.fitness) min, max = value.minmax -[(min - evolvable.fitness).abs, (max - evolvable.fitness).abs].min end def met?(evolvable) value.include?(evolvable.fitness) end end
Evolution moves a population from one generation to the next. It runs in three steps: selection, combination, and mutation. You can swap out any step with your own strategy.
Default pipeline:
- Selection – keep the most fit evolvables
- Combination – create offspring by recombining genes
- Mutation – add random variation to preserve diversity
Selection determines which evolvables will serve as parents for the next generation. You can control the selection process in several ways:
Set the selection size during population initialization:
population = MyEvolvable.new_population( selection: { size: 3 } )
Adjust the selection size after initialization:
population.selection_size = 4
Manually assign the selected evolvables:
population.selected_evolvables = [evolvable1, evolvable2]
Or evolve a custom selection directly:
population.evolve_selected([evolvable1, evolvable2])
This flexibility lets you implement custom selection strategies, overriding or augmenting the built-in behavior.
Combination is the process of creating new evolvables by mixing the genes of selected parents. This step drives the creation of the next generation by recombining traits in novel ways.
You can choose from several built-in combination strategies or implement your own.
By default, Evolvable uses Evolvable::GeneCombination, which delegates
gene-level behavior to individual gene classes.
To define custom combination logic for a gene type, implement:
YourGeneClass.combine(parent_1_gene, parent_2_gene)
Point Crossover
A classic genetic algorithm strategy that performs single or multi-point crossover by selecting random positions in the genome and swapping gene segments between parents.
- Single-point crossover (default): Swaps all genes after a randomly chosen position.
- Multi-point crossover: Alternates segments between multiple randomly chosen points.
Best for:
- Preserving beneficial gene blocks
- Problems where related traits are located near each other
Set your population to use this strategy during initialization with:
population = MyEvolvable.new_population( combination: Evolvable::PointCrossover.new(points_count: 2) )
Or update an existing population:
population.combination = Evolvable::PointCrossover.new(points_count: 3)
Uniform Crossover
Chooses genes independently at each position, selecting randomly from either parent with equal probability. No segments are preserved—each gene is treated in isolation.
Best for:
- Problems where gene order doesn't matter
- High genetic diversity and exploration
- Complex interdependencies across traits
Uniform crossover is especially effective when good traits are scattered across the genome.
Set your population to use this strategy during initialization with:
population = MyEvolvable.new_population( combination: Evolvable::UniformCrossover.new )
Or update an existing population:
population.combination = Evolvable::UniformCrossover.new
UniformCrossover Documentation
Mutation introduces genetic variation by randomly replacing genes with new ones. This helps the population explore new areas of the solution space and prevents premature convergence on suboptimal solutions.
Mutation is controlled by two key parameters:
- probability: Likelihood that an individual will undergo mutation (range: 0.0–1.0)
- rate: Fraction of genes to mutate within those individuals (range: 0.0–1.0)
A typical strategy is to start with higher mutation to encourage exploration:
population = MyEvolvable.new_population( mutation: { probability: 0.4, rate: 0.2 } )
Then later reduce the mutation rate to focus on refinement and convergence:
population.mutation_probability = 0.1 population.mutation_rate = 0.05
Gene clusters group related genes into reusable components that can be applied to multiple evolvable classes. This promotes clean organization, eliminates naming conflicts, and simplifies gene access.
Benefits:
- Reuse gene groups across multiple evolvables
- Prevent name collisions via automatic namespacing
- Treat clusters as structured subcomponents of a genome
- Access all genes in a cluster with a single method call
The ColorPaletteCluster below defines a group of genes commonly used for styling themes:
class ColorPaletteCluster include Evolvable::GeneCluster gene :primary, type: 'ColorGene', count: 1 gene :secondary, type: 'ColorGene', count: 1 gene :accent, type: 'ColorGene', count: 1 gene :neutral, type: 'ColorGene', count: 1 end
Use the cluster macro to apply the cluster to your evolvable class:
class Theme include Evolvable cluster :colors, type: ColorPaletteCluster def inspect_colors colors.join(", ") end end
When a cluster is applied, its genes are automatically namespaced with the cluster name:
- Access the full group:
theme.colors→ returns all genes in the colors cluster - Access individual genes:
theme.find_gene("colors-primary")
The Community module provides a framework for coordinating multiple evolvable populations
under a unified interface. Each population represents a distinct type of evolvable, and
each key returns a single evolvable instance drawn from its corresponding population.
Communities are ideal for simulations or systems where different components evolve in parallel but interact as part of a larger whole - such as ecosystems, design systems, or modular agents. Evolvables from different populations can co-evolve, influencing each other's fitness.
Use the evolvable_community macro to declare the set of named populations in the community.
Each population will have a corresponding method (e.g., fish_1, plant, shrimp) that
returns a single evolvable instance. You can evolve all populations together using the
evolve method, or per population.
Key Features
- Define a community composed of named populations
- Automatically generate accessors for each evolvable instance
- Coordinate evolution across populations through a shared interface
- Evolve all populations in a single call with
evolve(...)
This FishTank example sets up a community with four named populations:
class FishTank include Evolvable::Community evolvable_community fish_1: Fish, fish_2: Fish, plant: AquariumPlant, shrimp: CleanerShrimp def describe_tank puts "🐟 Fish 1: #{fish_1.name} (#{fish_1.color})" puts "🐟 Fish 2: #{fish_2.name} (#{fish_2.color})" puts "🌿 Plant: #{plant.name} (#{plant.color})" puts "🦐 Shrimp: #{shrimp.name} (#{shrimp.color})" end end
Initialize the community, describe the tank, and evolve each population:
tank = FishTank.new_community tank.describe_tank tank.evolve
Evolvable supports saving and restoring the state of both populations
and individual evolvable instances through a built-in Serializer.
By default, it uses Ruby's Marshal class for fast, portable binary serialization.
Serialization is useful for:
- Saving progress during long-running evolution
- Storing champion solutions for later reuse
- Transferring evolved populations between systems
- Creating checkpoints you can revert to
Both Population and individual evolvables expose dump and load methods
that use the Serializer internally.
Save a population to a file:
population = YourEvolvable.new_population population.evolve(count: 100) File.write("population.marshal", population.dump)
Restore and continue evolution:
data = File.read("population.marshal") restored = Evolvable::Population.load(data) restored.evolve(count: 100)
Save an individual evolvable's genome:
best = restored.best_evolvable File.write("champion.marshal", best.dump_genome)
Restore genome into a new evolvable:
raw = File.read("champion.marshal") champion = YourEvolvable.new_evolvable champion.load_genome(raw)
Bug reports and pull requests are welcome on GitHub at https://github.com/mattruzicka/evolvable.