Current section
Files
Jump to
Current section
Files
src/strategies/generational_strategy.erl
%% @doc Generational evolution strategy - the default (mu,lambda) batch evolution.
%%
%% This strategy implements traditional generational evolution:
%% 1. Evaluate entire population
%% 2. Select survivors based on fitness
%% 3. Breed offspring via crossover and mutation
%% 4. Form next generation from survivors + offspring
%% 5. Repeat
%%
%% This preserves all existing behavior from neuroevolution_server including:
%% - Fixed population size
%% - Tournament/top-n selection
%% - Sexual reproduction with crossover
%% - Weight mutation
%% - Optional NEAT-style speciation
%%
%% == Lifecycle Events ==
%%
%% Universal events (all strategies emit):
%% - individual_born - when offspring are created
%% - individual_died - when individuals are eliminated
%% - individual_evaluated - when fitness is computed
%%
%% Strategy-specific events:
%% - cohort_evaluated - all individuals in generation evaluated
%% - breeding_complete - selection and breeding finished
%% - generation_advanced - new generation begins
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(generational_strategy).
-behaviour(evolution_strategy).
-include("evolution_strategy.hrl").
-include("lifecycle_events.hrl").
-include("neuroevolution.hrl").
%% Behaviour callbacks
-export([
init/1,
handle_evaluation_result/3,
tick/1,
get_population_snapshot/1,
get_meta_inputs/1,
apply_meta_params/2
]).
%% Internal state record
-record(gen_state, {
%% Configuration
config :: neuro_config(),
params :: generational_params(),
%% Network factory module (default: network_factory, can override for testing)
network_factory = network_factory :: module(),
%% Population management
population = [] :: [individual()],
population_map = #{} :: #{individual_id() => individual()}, % O(1) lookup
population_size :: pos_integer(),
%% Evaluation-centric progress tracking (PRIMARY metric)
total_evaluations = 0 :: non_neg_integer(),
%% Cohort tracking (SECONDARY, for lineage - formerly "generation")
generation = 1 :: pos_integer(),
%% Evaluation state
evaluating = false :: boolean(),
evaluated_count = 0 :: non_neg_integer(),
evaluated_individuals = [] :: [individual()],
%% Statistics
best_fitness_ever = 0.0 :: float(),
last_best_fitness = 0.0 :: float(),
last_avg_fitness = 0.0 :: float(),
stagnation_count = 0 :: non_neg_integer(),
%% Speciation (optional)
species = [] :: [species()],
next_species_id = 1 :: pos_integer()
}).
%%% ============================================================================
%%% Behaviour Callbacks
%%% ============================================================================
%% @doc Initialize the generational strategy.
%%
%% Expects config map with:
%% - neuro_config - the full neuroevolution config
%% - strategy_params - optional generational_params record or map
%% - network_factory - optional module for network creation (default: network_factory)
-spec init(Config :: map()) -> {ok, #gen_state{}} | {error, term()}.
init(Config) ->
NeuroConfig = maps:get(neuro_config, Config),
%% Parse strategy params (defaults if not provided)
Params = parse_params(maps:get(strategy_params, Config, #{})),
%% Get network factory (for testability)
NetworkFactory = maps:get(network_factory, Config, network_factory),
%% Create initial population
Population = create_initial_population(NeuroConfig, NetworkFactory),
%% Build population map for O(1) lookup
PopulationMap = build_population_map(Population),
State = #gen_state{
config = NeuroConfig,
params = Params,
network_factory = NetworkFactory,
population = Population,
population_map = PopulationMap,
population_size = NeuroConfig#neuro_config.population_size
},
%% Emit individual_born events for initial population
BirthEvents = [create_birth_event(Ind, initial) || Ind <- Population],
%% Return initial state with birth events
%% Note: The server will handle emitting these events
{ok, State, BirthEvents}.
%% @doc Handle an individual evaluation result.
%%
%% Accumulates results until all individuals are evaluated,
%% then triggers selection and breeding.
-spec handle_evaluation_result(
IndividualId :: individual_id(),
FitnessResult :: map(),
State :: #gen_state{}
) -> strategy_result().
handle_evaluation_result(IndividualId, FitnessResult, State) ->
Fitness = maps:get(fitness, FitnessResult),
Metrics = maps:get(metrics, FitnessResult, #{}),
%% O(1) lookup and update via population map
PopMap = State#gen_state.population_map,
case maps:find(IndividualId, PopMap) of
{ok, Individual} ->
UpdatedInd = Individual#individual{fitness = Fitness, metrics = Metrics},
%% Update both map and list
NewPopMap = maps:put(IndividualId, UpdatedInd, PopMap),
UpdatedPop = update_individual_in_list(UpdatedInd, State#gen_state.population),
%% Track evaluation progress
NewEvaluatedCount = State#gen_state.evaluated_count + 1,
NewEvaluatedInds = [UpdatedInd | State#gen_state.evaluated_individuals],
%% Create individual_evaluated event
EvalEvent = #individual_evaluated{
id = IndividualId,
fitness = Fitness,
metrics = Metrics,
timestamp = erlang:timestamp(),
metadata = #{generation => State#gen_state.generation}
},
%% Check if all individuals have been evaluated
PopSize = State#gen_state.population_size,
case NewEvaluatedCount >= PopSize of
true ->
%% All evaluated - trigger breeding
handle_cohort_complete(State#gen_state{
population = UpdatedPop,
population_map = NewPopMap,
evaluated_count = NewEvaluatedCount,
evaluated_individuals = NewEvaluatedInds
}, [EvalEvent]);
false ->
%% Still evaluating - just update state
NewState = State#gen_state{
population = UpdatedPop,
population_map = NewPopMap,
evaluated_count = NewEvaluatedCount,
evaluated_individuals = NewEvaluatedInds
},
{[], [EvalEvent], NewState}
end;
error ->
%% Individual not found (shouldn't happen)
{[], [], State}
end.
%% @doc Periodic tick - not heavily used in generational strategy.
%%
%% Could be used for timeout handling or periodic statistics.
-spec tick(State :: #gen_state{}) -> strategy_result().
tick(State) ->
%% Generational strategy doesn't need periodic ticks
%% Everything is driven by evaluation results
{[], [], State}.
%% @doc Get a snapshot of the current population state.
-spec get_population_snapshot(State :: #gen_state{}) -> population_snapshot().
get_population_snapshot(State) ->
Population = State#gen_state.population,
%% Sort by fitness for summary
Sorted = lists:sort(
fun(A, B) -> A#individual.fitness >= B#individual.fitness end,
Population
),
%% Calculate fitness statistics
{BestFitness, AvgFitness, WorstFitness} = calculate_fitness_stats(Sorted),
%% Create individual summaries (include age = current_generation - generation_born)
CurrentGen = State#gen_state.generation,
Summaries = [summarize_individual(Ind, CurrentGen) || Ind <- Sorted],
#{
size => length(Population),
individuals => Summaries,
best_fitness => BestFitness,
avg_fitness => AvgFitness,
worst_fitness => WorstFitness,
species_count => length(State#gen_state.species),
generation => State#gen_state.generation,
extra => #{
stagnation_count => State#gen_state.stagnation_count,
best_fitness_ever => State#gen_state.best_fitness_ever
}
}.
%% @doc Get inputs for the meta-controller.
%%
%% Returns normalized values representing evolution progress.
-spec get_meta_inputs(State :: #gen_state{}) -> meta_inputs().
get_meta_inputs(State) ->
Population = State#gen_state.population,
%% Calculate current stats
{BestFitness, AvgFitness, WorstFitness} = calculate_fitness_stats(Population),
%% Fitness spread (diversity indicator)
FitnessSpread = case BestFitness of
+0.0 -> 0.0;
_ -> (BestFitness - WorstFitness) / BestFitness
end,
%% Improvement since last generation
Improvement = BestFitness - State#gen_state.last_best_fitness,
NormImprovement = normalize_value(Improvement, -100.0, 100.0),
%% Stagnation indicator (0 = not stagnant, 1 = very stagnant)
StagnationNorm = min(1.0, State#gen_state.stagnation_count / 20.0),
%% Generation progress (normalized to max 1000 generations)
GenProgress = min(1.0, State#gen_state.generation / 1000.0),
%% Diversity (approximate from fitness variance)
Variance = calculate_fitness_variance(Population, AvgFitness),
DiversityNorm = normalize_value(Variance, 0.0, 1000.0),
[
normalize_value(BestFitness, 0.0, 1000.0), % Best fitness
normalize_value(AvgFitness, 0.0, 1000.0), % Avg fitness
FitnessSpread, % Diversity
NormImprovement, % Improvement rate
StagnationNorm, % Stagnation
GenProgress, % Generation progress
DiversityNorm % Population diversity
].
%% @doc Apply meta-controller parameter adjustments.
-spec apply_meta_params(Params :: meta_params(), State :: #gen_state{}) -> #gen_state{}.
apply_meta_params(Params, State) ->
Config = State#gen_state.config,
StrategyParams = State#gen_state.params,
%% Update config with bounded values
NewConfig = Config#neuro_config{
mutation_rate = bound_value(
maps:get(mutation_rate, Params, Config#neuro_config.mutation_rate),
0.01, 1.0
),
mutation_strength = bound_value(
maps:get(mutation_strength, Params, Config#neuro_config.mutation_strength),
0.01, 2.0
),
selection_ratio = bound_value(
maps:get(selection_ratio, Params, Config#neuro_config.selection_ratio),
0.05, 0.50
)
},
%% Update strategy params if provided
NewParams = StrategyParams#generational_params{
mutation_rate = NewConfig#neuro_config.mutation_rate,
mutation_strength = NewConfig#neuro_config.mutation_strength,
selection_ratio = NewConfig#neuro_config.selection_ratio
},
State#gen_state{
config = NewConfig,
params = NewParams
}.
%%% ============================================================================
%%% Internal Functions - Cohort Complete / Breeding
%%% ============================================================================
%% @private Handle completion of evaluating entire cohort.
handle_cohort_complete(State, AccEvents) ->
Population = State#gen_state.population,
Generation = State#gen_state.generation,
Config = State#gen_state.config,
Params = State#gen_state.params,
%% Calculate batch evaluations for this cohort
PopSize = State#gen_state.population_size,
EvalsPerIndividual = Config#neuro_config.evaluations_per_individual,
BatchEvaluations = PopSize * EvalsPerIndividual,
NewTotalEvaluations = State#gen_state.total_evaluations + BatchEvaluations,
%% Sort population by fitness
Sorted = lists:sort(
fun(A, B) -> A#individual.fitness >= B#individual.fitness end,
Population
),
%% Calculate statistics
{BestFitness, AvgFitness, WorstFitness} = calculate_fitness_stats(Sorted),
%% Calculate improvement for velocity tracking
Improvement = BestFitness - State#gen_state.last_best_fitness,
%% Create cohort_evaluated event (legacy, for backward compat)
CohortEvent = #cohort_evaluated{
generation = Generation,
best_fitness = BestFitness,
avg_fitness = AvgFitness,
worst_fitness = WorstFitness,
population_size = length(Sorted),
timestamp = erlang:timestamp()
},
%% Create progress_checkpoint event (evaluation-centric, PRIMARY)
%% This enables the meta-controller and dashboard to track progress
%% in evaluation units rather than generation/cohort units
ProgressEvent = #progress_checkpoint{
total_evaluations = NewTotalEvaluations,
evaluations_since_last = BatchEvaluations,
cohort = Generation,
best_fitness = BestFitness,
avg_fitness = AvgFitness,
worst_fitness = WorstFitness,
population_size = PopSize,
improvement = Improvement,
timestamp = erlang:timestamp()
},
%% Perform selection
NumSurvivors = round(length(Sorted) * Params#generational_params.selection_ratio),
NumSurvivors2 = max(2, NumSurvivors), % At least 2 for breeding
Survivors = lists:sublist(Sorted, NumSurvivors2),
Eliminated = lists:nthtail(NumSurvivors2, Sorted),
%% Create death events for eliminated individuals
DeathEvents = [create_death_event(Ind, selection_pressure) || Ind <- Eliminated],
%% Breed offspring
NumOffspring = State#gen_state.population_size - NumSurvivors2,
NetworkFactory = State#gen_state.network_factory,
{Offspring, BirthEvents} = breed_offspring(Survivors, Config, Generation, NumOffspring, NetworkFactory),
%% Create breeding_complete event
BreedingEvent = #breeding_complete{
generation = Generation,
survivor_count = NumSurvivors2,
eliminated_count = length(Eliminated),
offspring_count = length(Offspring),
timestamp = erlang:timestamp()
},
%% Reset survivors for next generation
ResetSurvivors = [reset_individual(Ind, true) || Ind <- Survivors],
%% Form next population
NextPopulation = ResetSurvivors ++ Offspring,
%% Rebuild population map for next generation
NextPopMap = build_population_map(NextPopulation),
%% Update stagnation tracking
Improved = BestFitness > State#gen_state.best_fitness_ever,
NewStagnation = case Improved of
true -> 0;
false -> State#gen_state.stagnation_count + 1
end,
%% Create generation_advanced event
GenAdvancedEvent = #generation_advanced{
generation = Generation + 1,
previous_best_fitness = BestFitness,
previous_avg_fitness = AvgFitness,
population_size = length(NextPopulation),
species_count = length(State#gen_state.species),
timestamp = erlang:timestamp()
},
%% Update state for next generation
NewState = State#gen_state{
population = NextPopulation,
population_map = NextPopMap,
total_evaluations = NewTotalEvaluations, %% PRIMARY: Evaluation counter
generation = Generation + 1,
evaluating = false,
evaluated_count = 0,
evaluated_individuals = [],
best_fitness_ever = max(State#gen_state.best_fitness_ever, BestFitness),
last_best_fitness = BestFitness,
last_avg_fitness = AvgFitness,
stagnation_count = NewStagnation
},
%% Combine all events - ProgressEvent is the PRIMARY progress indicator
AllEvents = AccEvents ++ [ProgressEvent, CohortEvent] ++ DeathEvents ++ BirthEvents ++
[BreedingEvent, GenAdvancedEvent],
%% Actions: request evaluation of new population
EvalAction = {evaluate_batch, [Ind#individual.id || Ind <- NextPopulation]},
{[EvalAction], AllEvents, NewState}.
%%% ============================================================================
%%% Internal Functions - Population Management
%%% ============================================================================
%% @private Create initial random population.
%%
%% When topology_mutation_config is set, creates NEAT genomes for each
%% individual. Otherwise creates fixed-topology networks.
create_initial_population(Config, NetworkFactory) ->
PopSize = Config#neuro_config.population_size,
UseNeat = Config#neuro_config.topology_mutation_config =/= undefined,
lists:map(
fun(Index) ->
case UseNeat of
true ->
%% NEAT mode: create minimal genome and derive network
Genome = genome_factory:create_minimal(Config),
Network = genome_factory:to_network(Genome),
#individual{
id = {initial, Index},
network = Network,
genome = Genome,
generation_born = 1
};
false ->
%% Legacy mode: create fixed-topology network
Topology = Config#neuro_config.network_topology,
Network = NetworkFactory:create_feedforward(Topology),
#individual{
id = {initial, Index},
network = Network,
generation_born = 1
}
end
end,
lists:seq(1, PopSize)
).
%% @private Reset individual for next generation.
reset_individual(Ind, IsSurvivor) ->
Ind#individual{
fitness = 0.0,
metrics = #{},
is_survivor = IsSurvivor,
is_offspring = false
}.
%% @private Build a map from individual ID to individual for O(1) lookup.
build_population_map(Population) ->
lists:foldl(
fun(Ind, Acc) -> maps:put(Ind#individual.id, Ind, Acc) end,
#{},
Population
).
%% @private Update an individual in the population list.
%% Uses the individual's ID to find and replace.
update_individual_in_list(UpdatedInd, Population) ->
Id = UpdatedInd#individual.id,
lists:map(
fun(Ind) ->
case Ind#individual.id =:= Id of
true -> UpdatedInd;
false -> Ind
end
end,
Population
).
%% @private Breed offspring from survivors.
breed_offspring(Survivors, Config, Generation, Count, NetworkFactory) ->
breed_offspring(Survivors, Config, Generation, Count, NetworkFactory, [], []).
breed_offspring(_Survivors, _Config, _Generation, 0, _NetworkFactory, Offspring, Events) ->
{lists:reverse(Offspring), lists:reverse(Events)};
breed_offspring(Survivors, Config, Generation, Remaining, NetworkFactory, Offspring, Events) ->
%% Select two parents
{Parent1, Parent2} = select_parents(Survivors),
%% Create offspring via crossover and mutation using the factory
Child = create_offspring_with_factory(
Parent1, Parent2, Config, Generation + 1, NetworkFactory
),
%% Create birth event
BirthEvent = create_birth_event(Child, crossover, [Parent1#individual.id, Parent2#individual.id]),
breed_offspring(Survivors, Config, Generation, Remaining - 1, NetworkFactory,
[Child | Offspring], [BirthEvent | Events]).
%% @private Create offspring using the network factory.
%% This allows dependency injection for testing.
%%
%% When parents have genomes, uses neuroevolution_genetic for NEAT crossover.
%% Otherwise uses the NetworkFactory for legacy weight-only evolution.
create_offspring_with_factory(Parent1, Parent2, Config, Generation, NetworkFactory) ->
%% Check if NEAT mode (parents have genomes)
case {Parent1#individual.genome, Parent2#individual.genome} of
{Genome1, Genome2} when Genome1 =/= undefined, Genome2 =/= undefined ->
%% NEAT mode: use genetic operators module
neuroevolution_genetic:create_offspring(Parent1, Parent2, Config, Generation);
_ ->
%% Legacy mode: use network factory
ChildNetwork = NetworkFactory:crossover(
Parent1#individual.network,
Parent2#individual.network
),
MutatedNetwork = NetworkFactory:mutate(
ChildNetwork,
Config#neuro_config.mutation_strength
),
ChildId = make_ref(),
#individual{
id = ChildId,
network = MutatedNetwork,
parent1_id = Parent1#individual.id,
parent2_id = Parent2#individual.id,
generation_born = Generation,
is_offspring = true
}
end.
%% @private Select two parents for breeding.
select_parents(Survivors) when length(Survivors) >= 2 ->
%% Tournament selection with size 2
P1 = tournament_select(Survivors, 2),
P2 = tournament_select(Survivors, 2),
{P1, P2};
select_parents([Single]) ->
{Single, Single}.
%% @private Tournament selection.
tournament_select(Population, TournamentSize) ->
%% Select random individuals for tournament
Candidates = random_sample(Population, TournamentSize),
%% Return the one with highest fitness
lists:foldl(
fun(Ind, Best) ->
case Ind#individual.fitness > Best#individual.fitness of
true -> Ind;
false -> Best
end
end,
hd(Candidates),
tl(Candidates)
).
%% @private Random sample from list.
random_sample(List, N) when N >= length(List) -> List;
random_sample(List, N) ->
random_sample(List, N, []).
random_sample(_List, 0, Acc) -> Acc;
random_sample(List, N, Acc) ->
Index = rand:uniform(length(List)),
Item = lists:nth(Index, List),
random_sample(List, N - 1, [Item | Acc]).
%%% ============================================================================
%%% Internal Functions - Events
%%% ============================================================================
%% @private Create individual_born event.
create_birth_event(Individual, Origin) ->
create_birth_event(Individual, Origin, []).
create_birth_event(Individual, Origin, ParentIds) ->
#individual_born{
id = Individual#individual.id,
parent_ids = ParentIds,
timestamp = erlang:timestamp(),
origin = Origin,
metadata = #{
generation => Individual#individual.generation_born
}
}.
%% @private Create individual_died event.
create_death_event(Individual, Reason) ->
#individual_died{
id = Individual#individual.id,
reason = Reason,
final_fitness = Individual#individual.fitness,
timestamp = erlang:timestamp(),
metadata = #{}
}.
%%% ============================================================================
%%% Internal Functions - Utilities
%%% ============================================================================
%% @private Parse strategy params from map or record.
parse_params(Params) when is_record(Params, generational_params) ->
Params;
parse_params(Params) when is_map(Params) ->
#generational_params{
selection_method = maps:get(selection_method, Params, top_n),
selection_ratio = maps:get(selection_ratio, Params, 0.20),
tournament_size = maps:get(tournament_size, Params, 3),
mutation_rate = maps:get(mutation_rate, Params, 0.10),
mutation_strength = maps:get(mutation_strength, Params, 0.3),
crossover_rate = maps:get(crossover_rate, Params, 0.75),
elitism = maps:get(elitism, Params, true),
elite_count = maps:get(elite_count, Params, 1)
};
parse_params(_) ->
#generational_params{}.
%% @private Calculate fitness statistics from population.
calculate_fitness_stats([]) ->
{0.0, 0.0, 0.0};
calculate_fitness_stats(Population) ->
Fitnesses = [Ind#individual.fitness || Ind <- Population],
Best = lists:max(Fitnesses),
Worst = lists:min(Fitnesses),
Avg = lists:sum(Fitnesses) / length(Fitnesses),
{Best, Avg, Worst}.
%% @private Calculate fitness variance.
calculate_fitness_variance([], _Avg) -> 0.0;
calculate_fitness_variance(Population, Avg) ->
Fitnesses = [Ind#individual.fitness || Ind <- Population],
SumSquaredDiffs = lists:sum([(F - Avg) * (F - Avg) || F <- Fitnesses]),
SumSquaredDiffs / length(Fitnesses).
%% @private Summarize individual for snapshot.
summarize_individual(Ind, CurrentGeneration) ->
GenBorn = Ind#individual.generation_born,
Age = CurrentGeneration - GenBorn,
#{
id => Ind#individual.id,
fitness => Ind#individual.fitness,
is_survivor => Ind#individual.is_survivor,
is_offspring => Ind#individual.is_offspring,
generation_born => GenBorn,
age => Age
}.
%% @private Normalize value to 0-1 range.
normalize_value(Value, Min, Max) ->
Clamped = max(Min, min(Max, Value)),
(Clamped - Min) / (Max - Min).
%% @private Bound value to range.
bound_value(Value, Min, Max) ->
max(Min, min(Max, Value)).