Skip to content

Evolution Configuration

The evolve block’s top-level fields control the main evolution loop. All fields have sensible defaults - you only need to specify values you want to override.

This is the complete reference for all the knobs you can turn on the evolution engine. Most experiments work fine with defaults; this page is for when you need to understand what a parameter actually does, tune speciation for a particular search space, or squeeze more performance out of a long run.

.quale FieldConfig FieldDefaultDescription
populationPopulationSize200Number of genomes maintained each generation
generationsMaxGenerations5000Maximum generations before stopping
scenariosScenariosPerEval5Independent random scenarios per genome evaluation. Each scenario uses a different random layout. Results are averaged for robustness.
ticksMaxTicks300Simulation steps per scenario. Longer runs give agents more time to demonstrate behavior but slow evaluation.
seedSeed0 (random)Random seed for reproducibility. 0 means the runtime generates a random seed using rand.Uint64() (Go’s automatically-seeded global RNG).
agentsAgentsPerScenario1Brain instances per scenario. Set to 2 for multi-agent social experiments.

These parameters are not exposed in the .quale syntax but exist in config.EvolutionConfig:

Config FieldDefaultDescription
TournamentSize5Number of candidates in tournament selection
ElitismRate0.05Declared in config but not currently used by reproduce(). Actual elitism is hardcoded: the top genome of each species with 5+ members is preserved unchanged.
CrossoverRate0.7Probability of crossover vs. mutation-only reproduction
DiversityInterval100Generations between diversity injection events
DiversityRate0.05Fraction of population replaced with fresh genomes during diversity injection

NEAT-style speciation groups genomes with similar topologies into species, protecting structural innovation from being outcompeted before it has time to optimize.

speciation {
threshold: 3.0 // compatibility distance cutoff
target_species: 15 // desired number of species
stagnation: 15 generations // generations without improvement before removal
}
.quale FieldConfig FieldDefaultDescription
thresholdCompatibilityThreshold3.0Compatibility distance below which genomes are placed in the same species. Adjusted dynamically toward target_species.
target_speciesTargetSpecies15The runtime adjusts the threshold by +/-0.1 per generation, clamped to [0.5, 10.0], to maintain approximately this many species.
stagnationStagnationLimit15Generations without fitness improvement before a species is removed. The top 2 species by BestFitness are always exempt from stagnation removal regardless of how long they have stagnated.

The compatibility distance between two genomes is computed using three components:

ComponentCoefficient (default)Description
Excess genes1.0Connection genes beyond the range of the other genome
Disjoint genes1.0Connection genes within range but not shared
Weight difference0.4Average absolute weight difference of matching genes
distance = (excess_coeff * excess_count + disjoint_coeff * disjoint_count) / max(genome_size, 1)
+ weight_coeff * avg_weight_diff

Within each species:

  • Offspring count is proportional to the species’ share of total adjusted fitness
  • Adjusted fitness = raw fitness / species size (this prevents large species from dominating)
  • Every species gets at least one offspring slot
  • Species with 5+ members preserve the top genome as an elite (unchanged)
  • Remaining slots are filled by tournament selection + crossover (70% chance) or mutation-only (30% chance)

convergence {
plateau: 200 generations // window to check
threshold: 0.5 // minimum improvement required
}
.quale FieldConfig FieldDefaultDescription
plateauPlateauGenerations200Number of generations to look back over
thresholdConvergenceThreshold0.5Minimum best-fitness improvement over the plateau window

After each generation, the engine compares the best fitness at the beginning and end of the plateau window:

converged = (best_fitness_now - best_fitness_N_generations_ago) < threshold

When converged, the engine prints a message and exits the evolution loop. A final checkpoint is always saved regardless of convergence.


Fitness is computed entirely by the domain layer, not the engine. The engine provides the framework (evaluate genomes, track results, use fitness for selection), but the domain decides what fitness means.

Each Domain.Evaluate() call returns an EvalResult:

type EvalResult struct {
Fitness float64
Metrics map[string]float64
}
  • Fitness is the scalar score used for selection, reproduction, and convergence detection
  • Metrics is a domain-specific map of named behavioral measurements (e.g., "survival": 0.95, "food_eaten": 3.2, "idle": 0.1)

The domain computes fitness by applying the fitness objectives from the .quale file:

fitness = sum(verb_sign * metric_value * weight) for each fitness objective

Where verb_sign is +1 for maximize and reward, and -1 for penalize.

After evaluating all genomes, the engine aggregates results into GenerationStats:

type GenerationStats struct {
Generation int
BestFitness float64
AvgFitness float64
WorstFitness float64
SpeciesCount int
BestNodes int
BestConns int
BestMetrics map[string]float64 // metrics from the best genome
AvgMetrics map[string]float64 // population-averaged metrics
}

The BestMetrics and AvgMetrics maps carry domain-specific keys. The engine does not interpret these - it passes them to Domain.PrintStats() for display and records them in the history for analysis.

The complexity metric (genome enabled connection count) is always available regardless of domain. It is passed to every Domain.Evaluate() call as the connectionCount parameter.


Genome evaluation is the system’s primary bottleneck. The engine parallelizes it:

  • One goroutine per genome, bounded by a semaphore equal to runtime.NumCPU()
  • Deterministic RNG: Per-genome seeds are generated sequentially from the engine’s RNG before the parallel section begins. This ensures identical results regardless of goroutine scheduling order.
  • No shared mutable state: Each goroutine builds its own Brain from the genome, creates its own world, and runs independently. The Domain.Evaluate() method receives its own *rand.Rand and *core.Brain.

This means a 16-core machine evaluates up to 16 genomes simultaneously. For a population of 200 with 5 scenarios of 300 ticks each, this typically reduces generation time by 10-15x compared to sequential evaluation.