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, the Selection interface with 7 operators, crossovers as 3 CrossoverType × 3 PromptCrossoverMode, mutations as 5 MutationType, 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. replaceRate outside [0.1, 0.5] is clamped; default 0.3; only replaceRate × Size offspring replace the worst individuals. Built for online learning (see 24.6).
  • EvolveOnIdle — reuses the configured SurvivalRate and BreedingPoolRatio (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.DiversityThreshold or DominantLineageShare > 0.6. Note the threshold is DiversityThreshold=0.15, not the 0.05 some drafts cite.
  • Fitness sharing — applyFitnessSharing penalizes agents crowded in parameter space, driven through SelectionScore. 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)
}
OperatorMechanismDefault / key paramsSelectionStrategy key
Tournamentpick k at random, keep best, repeat n timesk default 3 (min 2)"tournament"
Ranklinear rank weight, best=N worst=1–"rank"
SUSsingle random start + evenly spaced pointers–"sus"
RouletteWheelfitness-proportional, scores shifted to non-negative–"roulette"
Truncationsort then take top N (deterministic elite)–"truncation"
LineageRankrank + lineage-diversity penaltythreshold 0.4, strength 0.5"lineage_rank"
NondominatedSortingNSGA-II: Pareto rank + crowding distance–"nsga2" / "nondominated" — deprecated, not wired (silent tournament fallback)

Real details:

  • Selectors score through effectiveScore(): rely on SelectionScore when non-zero (the post-fitness-sharing value), else fall back to Score. The point — let fitness sharing influence every selector without polluting the Score used for reporting/history.
  • Unevaluated agents (Score = ScoreUnevaluated = -1) sort to the end via SortByScore; SUS and roulette filter them out.
  • Tournament draws unique indices via Fisher–Yates partial shuffle; WithTournamentSeed gives reproducibility.
  • NSGA-II is a bolt-on: NondominatedSortingSelection checks DimensionScores; 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:

DimensionDirectionDefault weightMeaning
success_ratemaximize0.40higher success rate is better
qualitymaximize0.25higher output quality is better
costminimize0.20lower API cost is better
latencyminimize0.15lower 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 of Value∈[0,1]); with no evidence it returns a neutral 0.5 so evolution keeps exploring.
  • Crossover() is uniform (optional CrossoverGenome).

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

ItemValue
Internal default selection"" (= random; the public api/evolution default is "tournament")
Default breeding-pool ratio0.6
Number of mutation types5 (incl. MutationRoot)
Diversity-collapse injection thresholdDiversityThreshold=0.15 or DominantLineageShare>0.6
UngatedPatcher fitness thresholdsApply≥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:

TopicPath
Population & main evolution loopinternal/runtime/ares_evolution/genome/population.go
Defaultsinternal/runtime/ares_evolution/genome/population_config.go
Selection operatorsinternal/runtime/ares_evolution/genome/selection.go
Crossover / inheritanceinternal/runtime/ares_evolution/genome/crossover.go
Multi-objectiveinternal/runtime/ares_evolution/genome/multi_objective.go
Mutation typesinternal/runtime/ares_evolution/mutation/types.go
Mutation engineinternal/runtime/ares_evolution/mutation/mutator.go
Public APIapi/evolution/evolution.go, api/evolution/mutation/mutator.go
Orchestration factoryinternal/runtime/ares_evolution/genome_wiring_system.go
Runtime DAG genomeinternal/runtime/evolution/genome/workflow_genome.go
Candidate generation & releaseinternal/runtime/evolution/ga_generator.go, candidate_pipeline.go
UngatedPatcherinternal/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).