GA Evolution Deep Dive — From a Single Strategy to a Pluggable Population Engine
This is the full introduction to the Ares evolution engine. Module root:
internal/runtime/ares_evolution/genome. Every symbol we use exists in the repo:Population,doEvolve,TournamentSelection, theSelectioninterface with 7 operators, crossovers as 3CrossoverType× 3PromptCrossoverMode, mutations as 5MutationType, and NSGA-II multi-objective (ParetoDominance/CrowdingDistance). All numbers are real constants or config defaults from the source.
1. First, what GA is — and what it isn't
GA (Genetic Algorithm) is not a "tuning tool" here — it's a strategy generator. Given a root strategy, it produces candidate strategies generation after generation through selection, crossover and mutation, and a pluggable scorer decides who gets to stay.
It is also not the only evolution path. The repo holds several in parallel:
internal/runtime/ares_evolution/ # the GA population engine (the stage for this article)
internal/runtime/evolution/ # runtime evolution engine (Genome/Diff/UngatedPatcher)
internal/runtime/observability/flight/ # agent lineage / family tree (see 24.5)
One clarification: the internal default config does NOT hardcode tournament selection. DefaultPopulationConfig() in internal/runtime/ares_evolution/genome/population_config.go leaves SelectionStrategy empty (""), and in buildSelector() an empty string is equivalent to "random" — parents are drawn uniformly at random from the breeding pool. It's the public api/evolution DefaultPopulationConfig() that defaults to "tournament". Don't mix the two.
2. Architecture
flowchart LR
subgraph api["api/evolution (public interface + adapters)"]
PubPop[Population] --> PopAdapter
PubMut[Mutator] --> MutAdapter
PubPro[Promoter] --> ProAdapter
end
subgraph core["internal/runtime/ares_evolution/genome (core engine)"]
PopAdapter[populationAdapter] --> Population[Population]
Population --> doEvolve
doEvolve --> Sel[Selection: 7 selectors]
doEvolve --> X[Crossover: CrossoverType x PromptCrossoverMode]
doEvolve --> M[Mutate: mutation.Mutator]
Sel --> Pareto[NSGA-II multi-objective]
end
subgraph rt["internal/evolution (runtime evolution, Ch.8 candidate pipeline)"]
GAGen[GAGenerator] --> Cand[Candidate]
Cand --> CandidatePipeline[CandidatePipeline.Release]
end
Population -->|BestStrategy| api
The central architectural constraint is spelled out in the Genome interface doc (internal/runtime/evolution/genome/genome.go):
Genome → Generate Candidate → Diff Engine → RuntimePatch. The Genome's only job is Mutation, Snapshot and optional Fitness; deployment belongs to the Diff Engine + UngatedPatcher.
That is what makes the engine inherently pluggable — Genome doesn't know which subsystem it represents.
One change after 2026-07: Crossover and Fitness were removed from the core Genome interface because they had zero production callers. They now live behind optional interfaces CrossoverGenome / FitnessGenome, detected by type assertion:
if f, ok := child.(FitnessGenome); ok {
score, err := f.Fitness(ctx)
}
A real engineering decision: don't put unused methods in the interface.
3. Population: the skeleton
internal/runtime/ares_evolution/genome/population.go defines the core struct. It's richer than older drafts showed:
type Population struct {
Agents []*mutation.Strategy
Size int // target size (constant across generations)
Generation int
mu sync.RWMutex
cfg PopulationConfig // immutable config snapshot
rng *rand.Rand // deterministic RNG (#nosec G404)
bestScore float64 // all-time best (stagnation detection)
bestEver *mutation.Strategy // best-ever strategy (deployment)
paretoFront []*mutation.Strategy // NSGA-II Pareto front
stagnantGens int // consecutive no-improvement gens
currentMutationRate float64 // runtime rate (adaptive)
recoveryActions map[string]int // diversity-recovery action counters
history []GenerationHistoryEntry
HistoryMaxSize int
}
Reader/writer separation: Best() / Stats() / Snapshot() take the read lock; ScoreAgents() and doEvolve() take the write lock. Notably, ScoreAgents() deliberately calls the external scorer outside the lock — because a scorer may be an LLM or network I/O that blocks for seconds, and you don't want to pin the whole population while it runs.
The real default config
(from DefaultPopulationConfig(), contrary to the "tournament by default" myth):
return PopulationConfig{
Size: 20,
SurvivalRate: 0.6,
MutationRate: 0.2,
EliteCount: 3,
BreedingPoolRatio: 0.6,
SelectionStrategy: "", // "" == "random"
TournamentSize: 3,
MinMutationRate: 0.05,
MaxMutationRate: 0.5,
MaxStagnantGenerations: 10,
DiversityThreshold: 0.15,
...
}
NewPopulation validates EliteCount > Size and MinMutationRate > MaxMutationRate, then mutates the root into Size-1 initial variants.
The doEvolve pipeline
Evolve / EvolveOnIdle / EvolveSteadyState all converge on doEvolve:
flowchart TD
A[validate: mutator/crosser/non-empty/scored] --> B[SortByScore]
B --> C[evict aged agents if AgentMaxAge>0]
C --> D[keep survivors]
D --> E[preserve elites]
E --> F[preserve prompt diversity]
F --> G[generateOffspring: select parents -> crossover -> mutate by rate]
G --> H[assemble next gen + Generation++]
H --> I[updateBestEverLocked + refresh Pareto front]
I --> J[applyFitnessSharing penalize crowded regions]
J --> K[adaptive: adjustMutationRate + handleStagnation]
K --> L{Overall<DiversityThreshold or DominantLineageShare>0.6?}
L -->|yes| M[injectFreshMutantsLocked]
L -->|no| N[done]
Highlights:
EvolveSteadyState(replaceRate)— steady-state GA.replaceRateoutside[0.1, 0.5]is clamped; default0.3; onlyreplaceRate × Sizeoffspring replace the worst individuals. Built for online learning (see 24.6).EvolveOnIdle— reuses the configuredSurvivalRateandBreedingPoolRatio(default 0.6) for the breeding pool; intended for idle-time evolution. The doc comment is explicit: the "zero token" claim covers only selection/crossover/mutation, not the prerequisite scoring pass.- Diversity recovery — fresh mutants are injected when
report.Overall < cfg.DiversityThresholdorDominantLineageShare > 0.6. Note the threshold isDiversityThreshold=0.15, not the 0.05 some drafts cite. - Fitness sharing —
applyFitnessSharingpenalizes agents crowded in parameter space, driven throughSelectionScore. Constants:FitnessSharingSigma=0.3,FitnessNicheRadius=0.15,FitnessSharingSampleLimit=50,FitnessSharingSampleSize=30.
4. Selection: seven implementations behind one interface
All live in internal/runtime/ares_evolution/genome/selection.go, each implementing the shared Selection interface:
type Selection interface {
Select(ctx context.Context, population []*mutation.Strategy, n int) ([]*mutation.Strategy, error)
}| Operator | Mechanism | Default / key params | SelectionStrategy key |
|---|---|---|---|
| Tournament | pick k at random, keep best, repeat n times | k default 3 (min 2) | "tournament" |
| Rank | linear rank weight, best=N worst=1 | – | "rank" |
| SUS | single random start + evenly spaced pointers | – | "sus" |
| RouletteWheel | fitness-proportional, scores shifted to non-negative | – | "roulette" |
| Truncation | sort then take top N (deterministic elite) | – | "truncation" |
| LineageRank | rank + lineage-diversity penalty | threshold 0.4, strength 0.5 | "lineage_rank" |
| NondominatedSorting | NSGA-II: Pareto rank + crowding distance | – | "nsga2" / "nondominated" — deprecated, not wired (silent tournament fallback) |
Real details:
- Selectors score through
effectiveScore(): rely onSelectionScorewhen non-zero (the post-fitness-sharing value), else fall back toScore. The point — let fitness sharing influence every selector without polluting theScoreused for reporting/history. - Unevaluated agents (
Score = ScoreUnevaluated = -1) sort to the end viaSortByScore; SUS and roulette filter them out. - Tournament draws unique indices via Fisher–Yates partial shuffle;
WithTournamentSeedgives reproducibility. - NSGA-II is a bolt-on:
NondominatedSortingSelectionchecksDimensionScores; with no multi-objective data it falls back to tournament. Within a front, it picks the subset by descending crowding distance (with a comment documenting a classic parallel-array sort bug avoided).
Population.buildSelector() maps strings to selectors; "" / "random" mean "no selector (random parents)".
5. Crossover and mutation
Crossover (internal/runtime/ares_evolution/genome/crossover.go)
3 CrossoverTypes for parameter recombination:
CrossoverUniform // each param independently taken 50/50 from either parent (default)
CrossoverTwoPoint // two cut points split ordered params into 3; swap the middle segment
CrossoverSegment // take one contiguous block from parent B, rest from A
3 PromptCrossoverModes:
PromptInherit // inherit the higher-scoring parent's full prompt (default)
PromptHalfSplit // first half from A, second half from B (half-sentence split)
PromptUniform // pick either parent at random, equal probability, boosts diversity
Construct with NewCrossover(WithSeed, WithDeterministicIDs, WithPromptMode, WithCrossoverType).
Mutation (internal/runtime/ares_evolution/mutation/types.go)
MutationType has 5 members:
MutationParameter // parameter value mutation -> "parameter"
MutationPrompt // prompt template mutation -> "prompt"
MutationTool // tool config mutation (Params["tools"] replaced) -> "tool"
MutationCrossover // a strategy produced by crossover (source marker) -> "crossover"
MutationRoot // root/initial strategy -> "root"
Generation is handled by the Mutator in internal/runtime/ares_evolution/mutation, built via NewMutator(WithParamRanges(...), WithPromptPool(...), WithToolPool(...)). There is also an experience-guided variant ExperienceGuidedMutator (mutation/guided_mutator.go) that biases mutation with historical performance, and an adaptive mutation rate (mutation/adaptive_distribution.go) wired into adjustMutationRateLocked() inside doEvolve.
6. Multi-objective: NSGA-II
internal/runtime/ares_evolution/genome/multi_objective.go. The four default dimensions come from DefaultDimensionDirections / DefaultDimensionWeights:
| Dimension | Direction | Default weight | Meaning |
|---|---|---|---|
success_rate | maximize | 0.40 | higher success rate is better |
quality | maximize | 0.25 | higher output quality is better |
cost | minimize | 0.20 | lower API cost is better |
latency | minimize | 0.15 | lower latency is better |
Core functions:
func ParetoDominance(a, b *mutation.Strategy) bool // a dominates b iff strictly better in >=1 dim, no worse in all others
func ParetoRank(strategies) []int // non-dominated sorting, 0 = best front
func CrowdingDistance(front) []float64 // within-front crowding (boundaries = infinity)
Not wired in 0.3.2: ScoreAgentsMulti and Population.ParetoFrontStrategy() are deprecated with zero production callers, and nothing populates DimensionScores — so selecting "nsga2" / "nondominated" silently falls back to single-objective tournament selection. The Pareto machinery is retained for SDK callers that feed DimensionScores themselves.
7. A workflow you can assemble: WiredEvolutionSystem
internal/runtime/ares_evolution/genome_wiring_system.go offers NewWiredEvolutionSystem(...) — a central factory wiring Population, scorer, lineage, guardrails, diff, coordinator — and RunIdleEvolution(ctx, system, n) runs N generations, roughly:
score → evolve → record lineage → LLM reflection → hypothesis generation → diff engine → coordinator → after-generation hook
This is where GA hooks into the runtime evolution pipeline in internal/runtime/evolution.
8. Runtime evolution: Genome / Diff / UngatedPatcher (Ch.8 candidate pipeline)
internal/runtime/evolution/genome/workflow_genome.go is the genuine runtime DAG genome:
- 9 mutation operators
wfOp*: insert / remove / replace / parallelize / serialize / swap / split / merge / set-metadata — one is chosen at random and applied to the DAG. DefaultWorkflowGenomeConfig():AgentPool=["default"],MaxNodes=20,InsertionRate=0.3,PruneRate=0.2.Fitness()reads real flight-recorder evidence (avgFitnessValue, mean ofValue∈[0,1]); with no evidence it returns a neutral 0.5 so evolution keeps exploring.Crossover()is uniform (optionalCrossoverGenome).
On the instruction side, GAGenerator in internal/runtime/evolution/ga_generator.go mutates a role's stable instructions into Candidates. CandidatePipeline.Release (candidate_pipeline.go) routes a verified candidate through the coordinator decision + canary deployment pipeline:
Candidate(verified) → RuntimePatch → coordinator.Submit → Evaluate
→ DecisionApply → deployment (staging→live) → ProfileStore.SetStable + audit
The coordinator's default policy is a real constant (internal/runtime/evolution/coordinator/coordinator.go):
AutoApplyThreshold: 8, // priority >= 8 auto-applies
MaxPatchesPerMinute: 4,
MinFitnessThreshold: 30.0, // GA patches below this are rejected
ApplyFitnessThreshold: 70.0, // GA patches at/above this auto-apply; [30,70) goes to delay for review
The doc comment notes five genomes (memory/knowledge/recovery/workflow/scheduler) each scale their success-rate mean from [0,1] to [0,100] — which is why it's 30/70.
9. Real defaults
| Item | Value |
|---|---|
| Internal default selection | "" (= random; the public api/evolution default is "tournament") |
| Default breeding-pool ratio | 0.6 |
| Number of mutation types | 5 (incl. MutationRoot) |
| Diversity-collapse injection threshold | DiversityThreshold=0.15 or DominantLineageShare>0.6 |
| UngatedPatcher fitness thresholds | Apply≥70 / Reject<30 / delay[30,70) |
10. Closing thoughts
The value of the evolution engine isn't "which operator is best" — it's pluggable and measurable. In the code you'll find 7 selection operators, 3+3 crossover/inheritance modes, 5 mutation types, an NSGA-II front, and a complete pipeline from candidate to canary deployment to stable. There is no "best config" preset — what you get is a set of tools that combine to cover everything from fast convergence to multi-objective trade-offs.
Appendix
[A] Key code locations:
| Topic | Path |
|---|---|
| Population & main evolution loop | internal/runtime/ares_evolution/genome/population.go |
| Defaults | internal/runtime/ares_evolution/genome/population_config.go |
| Selection operators | internal/runtime/ares_evolution/genome/selection.go |
| Crossover / inheritance | internal/runtime/ares_evolution/genome/crossover.go |
| Multi-objective | internal/runtime/ares_evolution/genome/multi_objective.go |
| Mutation types | internal/runtime/ares_evolution/mutation/types.go |
| Mutation engine | internal/runtime/ares_evolution/mutation/mutator.go |
| Public API | api/evolution/evolution.go, api/evolution/mutation/mutator.go |
| Orchestration factory | internal/runtime/ares_evolution/genome_wiring_system.go |
| Runtime DAG genome | internal/runtime/evolution/genome/workflow_genome.go |
| Candidate generation & release | internal/runtime/evolution/ga_generator.go, candidate_pipeline.go |
| UngatedPatcher | internal/runtime/evolution/coordinator/coordinator.go |
[B] Run it yourself: go run ./examples/10-ga-full-evolution.
[C] Not covered here: TieredScorer (24.2), selection benchmarks (24.3), Promoter (24.4), genealogy (24.5), real-world lessons (24.6).