Current section

Files

Jump to
macula_neuroevolution src neuroevolution_server.erl
Raw

src/neuroevolution_server.erl

%% @doc Population-based evolutionary training server.
%%
%% This gen_server manages a population of neural network individuals,
%% running them through domain-specific evaluation and evolving them
%% across generations.
%%
%% == Generation Lifecycle ==
%%
%% Each generation follows this cycle:
%% <ol>
%% <li>Evaluate all individuals in parallel using the configured evaluator</li>
%% <li>Calculate fitness for each individual</li>
%% <li>Select survivors (top performers)</li>
%% <li>Breed survivors to create offspring via crossover + mutation</li>
%% <li>Form new generation from survivors and offspring</li>
%% <li>Repeat</li>
%% </ol>
%%
%% == Configuration ==
%%
%% The server is configured via a `#neuro_config{}' record that specifies:
%% <ul>
%% <li>Population size and selection ratio</li>
%% <li>Mutation rate and strength</li>
%% <li>Network topology (inputs, hidden layers, outputs)</li>
%% <li>Evaluator module (implements `neuroevolution_evaluator' behaviour)</li>
%% <li>Optional event handler for notifications</li>
%% </ul>
%%
%% == Usage Example ==
%%
%% ```
%% Config = #neuro_config{
%% population_size = 50,
%% selection_ratio = 0.20,
%% mutation_rate = 0.10,
%% mutation_strength = 0.3,
%% network_topology = {42, [16, 8], 6},
%% evaluator_module = my_game_evaluator
%% },
%% {ok, Pid} = neuroevolution_server:start_link(Config),
%% neuroevolution_server:start_training(Pid),
%% %% ... training runs ...
%% neuroevolution_server:stop_training(Pid).
%% '''
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(neuroevolution_server).
-behaviour(gen_server).
-include("neuroevolution.hrl").
%% API
-export([
start_link/1,
start_link/2,
start_training/1,
stop_training/1,
get_stats/1,
get_population/1,
update_config/2
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Start the neuroevolution server with given configuration.
-spec start_link(Config) -> {ok, pid()} | {error, term()} when
Config :: neuro_config().
start_link(Config) ->
start_link(Config, []).
%% @doc Start the neuroevolution server with configuration and options.
%%
%% Options:
%% - `{id, Id}' - Server identifier (default: make_ref())
%% - `{name, Name}' - Register with given name
-spec start_link(Config, Options) -> {ok, pid()} | {error, term()} when
Config :: neuro_config(),
Options :: proplists:proplist().
start_link(Config, Options) ->
Id = proplists:get_value(id, Options, make_ref()),
case proplists:get_value(name, Options) of
undefined ->
gen_server:start_link(?MODULE, {Id, Config}, []);
Name ->
gen_server:start_link(Name, ?MODULE, {Id, Config}, [])
end.
%% @doc Start the evolutionary training process.
-spec start_training(ServerRef) -> {ok, started | already_running} when
ServerRef :: pid() | atom().
start_training(ServerRef) ->
gen_server:call(ServerRef, start_training).
%% @doc Stop the evolutionary training process.
-spec stop_training(ServerRef) -> ok when
ServerRef :: pid() | atom().
stop_training(ServerRef) ->
gen_server:call(ServerRef, stop_training).
%% @doc Get current training statistics.
-spec get_stats(ServerRef) -> {ok, Stats} when
ServerRef :: pid() | atom(),
Stats :: map().
get_stats(ServerRef) ->
gen_server:call(ServerRef, get_stats).
%% @doc Get the current population.
-spec get_population(ServerRef) -> {ok, [individual()]} when
ServerRef :: pid() | atom().
get_population(ServerRef) ->
gen_server:call(ServerRef, get_population).
%% @doc Update configuration dynamically (used by external meta-controllers).
%%
%% Allows updating hyperparameters like mutation_rate, mutation_strength,
%% and selection_ratio between generations. This is used by the Elixir
%% Liquid Conglomerate meta-controller to feed its recommendations into
%% the Erlang neuroevolution server.
%%
%% Supported parameters:
%% - `mutation_rate' - Probability of mutation (0.0 to 1.0)
%% - `mutation_strength' - Magnitude of mutations (0.0 to 2.0)
%% - `selection_ratio' - Fraction of population that survives (0.0 to 1.0)
%%
%% @param ServerRef Server reference (pid or registered name)
%% @param Params Map of parameter name to new value
%% @returns {ok, UpdatedConfig} on success
-spec update_config(ServerRef, Params) -> {ok, map()} when
ServerRef :: pid() | atom(),
Params :: #{atom() => number()}.
update_config(ServerRef, Params) ->
gen_server:call(ServerRef, {update_config, Params}).
%%% ============================================================================
%%% gen_server Callbacks
%%% ============================================================================
%% @private
init({Id, Config}) ->
error_logger:info_msg(
"[neuroevolution_server] Initializing with population size ~p~n",
[Config#neuro_config.population_size]
),
%% Create initial random population
Population = create_initial_population(Config),
TotalGames = Config#neuro_config.population_size *
Config#neuro_config.evaluations_per_individual,
%% Start meta-controller if configured
MetaController = maybe_start_meta_controller(Config),
State = #neuro_state{
id = Id,
config = Config,
population = Population,
total_games = TotalGames,
meta_controller = MetaController
},
{ok, State}.
%% @private
handle_call(start_training, _From, State = #neuro_state{running = true}) ->
{reply, {ok, already_running}, State};
handle_call(start_training, _From, State) ->
error_logger:info_msg(
"[neuroevolution_server] Starting training - Generation ~p~n",
[State#neuro_state.generation]
),
NewState = State#neuro_state{
running = true,
evaluating = true,
games_completed = 0
},
%% Notify event handler
notify_event(NewState, {training_started, State#neuro_state.config}),
%% Start evaluation
self() ! evaluate_generation,
{reply, {ok, started}, NewState};
handle_call(stop_training, _From, State) ->
notify_event(State, {training_stopped, State#neuro_state.generation}),
NewState = State#neuro_state{running = false, evaluating = false},
{reply, ok, NewState};
handle_call(get_stats, _From, State) ->
Stats = build_stats(State),
{reply, {ok, Stats}, State};
handle_call(get_population, _From, State) ->
{reply, {ok, State#neuro_state.population}, State};
handle_call({update_config, Params}, _From, State) ->
%% Apply external meta-controller (LC) parameters to config
Config = State#neuro_state.config,
UpdatedConfig = apply_meta_params(Config, Params),
%% Get speciation config for response (use default if not set)
SpecConfig = get_speciation_config(UpdatedConfig#neuro_config.speciation_config),
%% Return the current config values as confirmation (includes speciation)
ConfigMap = #{
mutation_rate => UpdatedConfig#neuro_config.mutation_rate,
mutation_strength => UpdatedConfig#neuro_config.mutation_strength,
selection_ratio => UpdatedConfig#neuro_config.selection_ratio,
compatibility_threshold => SpecConfig#speciation_config.compatibility_threshold,
target_species => SpecConfig#speciation_config.target_species,
min_species_size => SpecConfig#speciation_config.min_species_size,
max_stagnation => SpecConfig#speciation_config.max_stagnation,
species_elitism => SpecConfig#speciation_config.species_elitism,
interspecies_mating_rate => SpecConfig#speciation_config.interspecies_mating_rate
},
NewState = State#neuro_state{config = UpdatedConfig},
{reply, {ok, ConfigMap}, NewState};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
%% @private
handle_cast(_Msg, State) ->
{noreply, State}.
%% @private
handle_info(evaluate_generation, State = #neuro_state{running = false}) ->
{noreply, State};
handle_info(evaluate_generation, State) ->
error_logger:info_msg(
"[neuroevolution_server] Evaluating generation ~p~n",
[State#neuro_state.generation]
),
%% Notify generation start
notify_event(State, {generation_started, State#neuro_state.generation}),
Config = State#neuro_state.config,
Population = State#neuro_state.population,
%% Choose evaluation mode
NewState = case Config#neuro_config.evaluation_mode of
distributed ->
start_distributed_evaluation(Population, State);
direct ->
start_direct_evaluation(Population, State)
end,
{noreply, NewState#neuro_state{evaluating = true}};
%% Handle distributed evaluation result
handle_info({neuro_event, _Topic, {evaluated, Result}}, State)
when State#neuro_state.evaluating =:= true,
map_size(State#neuro_state.pending_evaluations) > 0 ->
handle_distributed_eval_result(Result, State);
%% Handle evaluation timeout
handle_info({eval_timeout, TimeoutRef}, #neuro_state{eval_timeout_ref = TimeoutRef} = State)
when State#neuro_state.evaluating =:= true ->
handle_evaluation_timeout(State);
handle_info({game_completed, _Result}, State) ->
%% Update progress counter
NewCompleted = State#neuro_state.games_completed + 1,
NewState = State#neuro_state{games_completed = NewCompleted},
%% Notify progress every 10 games
case NewCompleted rem 10 of
0 ->
notify_event(NewState, {evaluation_progress,
State#neuro_state.generation,
NewCompleted,
State#neuro_state.total_games
});
_ ->
ok
end,
{noreply, NewState};
handle_info({evaluation_complete, EvaluatedPopulation}, State) ->
handle_evaluation_complete(State, EvaluatedPopulation);
handle_info(_Info, State) ->
{noreply, State}.
%% @private
terminate(_Reason, _State) ->
ok.
%% @private
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%% ============================================================================
%%% Internal Functions - Population Creation
%%% ============================================================================
%% @private
%% @doc Create initial population with random networks.
create_initial_population(Config) ->
{InputSize, HiddenLayers, OutputSize} = Config#neuro_config.network_topology,
PopSize = Config#neuro_config.population_size,
lists:map(
fun(I) ->
Network = network_evaluator:create_feedforward(InputSize, HiddenLayers, OutputSize),
#individual{
id = {initial, I},
network = Network,
generation_born = 1
}
end,
lists:seq(1, PopSize)
).
%%% ============================================================================
%%% Internal Functions - Evaluation
%%% ============================================================================
%% @private
%% @doc Evaluate all individuals in parallel.
evaluate_population_parallel(Population, Config, ServerPid) ->
EvaluatorModule = Config#neuro_config.evaluator_module,
EvaluatorOptions = Config#neuro_config.evaluator_options,
GamesPerIndividual = Config#neuro_config.evaluations_per_individual,
Options = maps:merge(EvaluatorOptions, #{
games => GamesPerIndividual,
notify_pid => ServerPid
}),
%% Spawn a worker for each individual
Refs = lists:map(
fun(Individual) ->
Ref = make_ref(),
ParentPid = self(),
spawn_link(fun() ->
Result = neuroevolution_evaluator:evaluate_individual(
Individual, EvaluatorModule, Options
),
ParentPid ! {eval_result, Ref, Result}
end),
{Ref, Individual}
end,
Population
),
%% Collect results
collect_eval_results(Refs, []).
%% @private
collect_eval_results([], Acc) ->
lists:reverse(Acc);
collect_eval_results(Refs, Acc) ->
receive
{eval_result, Ref, {ok, EvaluatedIndividual}} ->
NewRefs = lists:keydelete(Ref, 1, Refs),
collect_eval_results(NewRefs, [EvaluatedIndividual | Acc]);
{eval_result, Ref, {error, _Reason}} ->
%% On error, keep original individual with zero fitness
case lists:keyfind(Ref, 1, Refs) of
{Ref, Original} ->
NewRefs = lists:keydelete(Ref, 1, Refs),
collect_eval_results(NewRefs, [Original | Acc]);
false ->
collect_eval_results(Refs, Acc)
end
after 300000 -> %% 5 minute timeout
error_logger:error_msg("[neuroevolution_server] Evaluation timeout~n"),
%% Return what we have
Remaining = [Ind || {_, Ind} <- Refs],
lists:reverse(Acc) ++ Remaining
end.
%%% ============================================================================
%%% Internal Functions - Direct Evaluation Mode
%%% ============================================================================
%% @private
%% @doc Start direct (local) evaluation - the default mode.
start_direct_evaluation(Population, State) ->
Config = State#neuro_state.config,
ServerPid = self(),
%% Spawn evaluation task
spawn_link(fun() ->
Results = evaluate_population_parallel(Population, Config, ServerPid),
ServerPid ! {evaluation_complete, Results}
end),
State.
%%% ============================================================================
%%% Internal Functions - Distributed Evaluation Mode
%%% ============================================================================
%% @private
%% @doc Start distributed evaluation via event publishing.
%%
%% Publishes evaluate_request events for each individual and subscribes
%% to the evaluated topic for results.
start_distributed_evaluation(Population, State) ->
Config = State#neuro_state.config,
Realm = Config#neuro_config.realm,
Timeout = Config#neuro_config.evaluation_timeout,
%% Subscribe to results topic
ResultTopic = neuroevolution_events:evaluated_topic(Realm),
ok = neuroevolution_events:subscribe(ResultTopic),
%% Publish evaluation requests for each individual
EvalTopic = neuroevolution_events:evaluate_topic(Realm),
EvaluatorOptions = Config#neuro_config.evaluator_options,
GamesPerIndividual = Config#neuro_config.evaluations_per_individual,
PendingEvaluations = lists:foldl(
fun(Individual, Acc) ->
RequestId = make_ref(),
Request = #{
request_id => RequestId,
realm => Realm,
individual_id => Individual#individual.id,
network => Individual#individual.network,
options => maps:merge(EvaluatorOptions, #{
games => GamesPerIndividual
})
},
ok = neuroevolution_events:publish(EvalTopic, {evaluate_request, Request}),
maps:put(RequestId, {Individual#individual.id, Individual}, Acc)
end,
#{},
Population
),
%% Set up timeout
TimeoutRef = make_ref(),
erlang:send_after(Timeout, self(), {eval_timeout, TimeoutRef}),
State#neuro_state{
pending_evaluations = PendingEvaluations,
eval_timeout_ref = TimeoutRef
}.
%% @private
%% @doc Handle a distributed evaluation result.
handle_distributed_eval_result(Result, State) ->
RequestId = maps:get(request_id, Result),
PendingEvaluations = State#neuro_state.pending_evaluations,
case maps:find(RequestId, PendingEvaluations) of
{ok, {_IndId, Original}} ->
%% Update individual with metrics from result
Metrics = maps:get(metrics, Result, #{}),
EvaluatedInd = Original#individual{metrics = Metrics},
%% Remove from pending
NewPending = maps:remove(RequestId, PendingEvaluations),
NewState = State#neuro_state{pending_evaluations = NewPending},
%% Check if all evaluations are complete
case maps:size(NewPending) of
0 ->
%% All done - collect results and proceed
finish_distributed_evaluation(NewState, [EvaluatedInd]);
_ ->
%% Store evaluated individual and wait for more
%% We need to accumulate evaluated individuals
%% Add to a results accumulator in state
AccumulatedResults = get_accumulated_results(State),
NewAccumulated = [EvaluatedInd | AccumulatedResults],
{noreply, store_accumulated_results(NewState, NewAccumulated)}
end;
error ->
%% Unknown request ID - ignore (might be duplicate or from previous generation)
{noreply, State}
end.
%% @private
%% @doc Handle evaluation timeout for distributed mode.
handle_evaluation_timeout(State) ->
PendingEvaluations = State#neuro_state.pending_evaluations,
PendingCount = maps:size(PendingEvaluations),
error_logger:warning_msg(
"[neuroevolution_server] Distributed evaluation timeout. ~p evaluations still pending.~n",
[PendingCount]
),
%% Use original individuals for pending (with zero fitness)
PendingIndividuals = [Ind || {_IndId, Ind} <- maps:values(PendingEvaluations)],
%% Combine with accumulated results
AccumulatedResults = get_accumulated_results(State),
AllResults = AccumulatedResults ++ PendingIndividuals,
finish_distributed_evaluation(State, AllResults).
%% @private
%% @doc Finish distributed evaluation and proceed to generation complete.
finish_distributed_evaluation(State, AdditionalResults) ->
Config = State#neuro_state.config,
Realm = Config#neuro_config.realm,
%% Unsubscribe from results topic
ResultTopic = neuroevolution_events:evaluated_topic(Realm),
ok = neuroevolution_events:unsubscribe(ResultTopic),
%% Cancel timeout if still pending
case State#neuro_state.eval_timeout_ref of
undefined -> ok;
_Ref -> ok %% Timer message will be ignored due to ref mismatch
end,
%% Combine all results
AccumulatedResults = get_accumulated_results(State),
AllResults = AccumulatedResults ++ AdditionalResults,
%% Clean up distributed state
CleanState = State#neuro_state{
pending_evaluations = #{},
eval_timeout_ref = undefined
},
CleanState2 = clear_accumulated_results(CleanState),
%% Proceed to handle_evaluation_complete
handle_evaluation_complete(CleanState2, AllResults).
%% @private
%% @doc Get accumulated evaluation results from process dictionary.
%% Using process dictionary for simplicity - could also add to record.
get_accumulated_results(#neuro_state{}) ->
case get(distributed_eval_results) of
undefined -> [];
Results -> Results
end.
%% @private
%% @doc Store accumulated results in process dictionary.
store_accumulated_results(State, Results) ->
put(distributed_eval_results, Results),
State.
%% @private
%% @doc Clear accumulated results from process dictionary.
clear_accumulated_results(State) ->
erase(distributed_eval_results),
State.
%%% ============================================================================
%%% Internal Functions - Generation Complete
%%% ============================================================================
%% @private
handle_evaluation_complete(State, EvaluatedPopulation) ->
Config = State#neuro_state.config,
%% Calculate fitness for all individuals
Population = lists:map(
fun(Ind) ->
Fitness = calculate_fitness(Ind, Config),
Ind#individual{fitness = Fitness}
end,
EvaluatedPopulation
),
%% Sort by fitness descending
Sorted = lists:sort(
fun(A, B) -> A#individual.fitness >= B#individual.fitness end,
Population
),
Best = hd(Sorted),
AvgFitness = neuroevolution_stats:avg_fitness(Sorted),
error_logger:info_msg(
"[neuroevolution_server] Gen ~p complete - Best: ~.2f, Avg: ~.2f~n",
[State#neuro_state.generation, Best#individual.fitness, AvgFitness]
),
%% Build generation stats - include actual individuals (not just IDs) for UI
GenStats = #generation_stats{
generation = State#neuro_state.generation,
best_fitness = Best#individual.fitness,
avg_fitness = AvgFitness,
worst_fitness = (lists:last(Sorted))#individual.fitness,
best_individual_id = Best#individual.id,
survivors = [], % Will be filled after next generation is created
eliminated = [],
offspring = []
},
%% Update meta-controller and get new hyperparameters
{UpdatedConfig, NewState1} = maybe_update_meta_controller(GenStats, Config, State),
%% Create next generation with (possibly updated) config
{NextPopulation, GenResults, BreedingEvents} = create_next_generation(
Sorted, UpdatedConfig, State#neuro_state.generation
),
%% Update generation stats with actual results
FinalGenStats = GenStats#generation_stats{
survivors = maps:get(survivors, GenResults, []), % Full individuals
eliminated = maps:get(eliminated, GenResults, []), % Full individuals
offspring = [I#individual.id || I <- maps:get(offspring, GenResults, [])]
},
%% Notify generation complete
notify_event(NewState1, {generation_complete, FinalGenStats}),
%% Update state
NewState = NewState1#neuro_state{
config = UpdatedConfig,
population = NextPopulation,
generation = State#neuro_state.generation + 1,
best_fitness_ever = max(State#neuro_state.best_fitness_ever, Best#individual.fitness),
last_gen_best = Best#individual.fitness,
last_gen_avg = AvgFitness,
generation_history = [{State#neuro_state.generation, Best#individual.fitness, AvgFitness}
| lists:sublist(State#neuro_state.generation_history, 49)],
last_gen_results = GenResults,
breeding_events = BreedingEvents,
evaluating = false,
games_completed = 0
},
%% Check stopping conditions and continue if appropriate
case should_stop(NewState) of
{true, Reason} ->
%% Notify training completed
notify_event(NewState, {training_complete, #{
reason => Reason,
generation => NewState#neuro_state.generation - 1,
best_fitness => NewState#neuro_state.best_fitness_ever,
best_individual => Best
}}),
StoppedState = NewState#neuro_state{running = false},
{noreply, StoppedState};
false ->
_ = case NewState#neuro_state.running of
true ->
erlang:send_after(500, self(), evaluate_generation);
false ->
ok
end,
{noreply, NewState}
end.
%% @private
calculate_fitness(Individual, Config) ->
EvaluatorModule = Config#neuro_config.evaluator_module,
Metrics = Individual#individual.metrics,
%% Try evaluator's calculate_fitness, fall back to default
try
EvaluatorModule:calculate_fitness(Metrics)
catch
error:undef ->
neuroevolution_evaluator:default_fitness(Metrics)
end.
%% @private
%% @doc Check if training should stop based on stopping conditions.
%% Returns {true, Reason} if should stop, false otherwise.
-spec should_stop(neuro_state()) -> {true, atom()} | false.
should_stop(#neuro_state{running = false}) ->
{true, stopped};
should_stop(#neuro_state{config = Config, generation = Generation, best_fitness_ever = BestFitness}) ->
%% Check target fitness first (most common stopping condition)
case Config#neuro_config.target_fitness of
TargetFitness when is_number(TargetFitness), BestFitness >= TargetFitness ->
{true, target_fitness_reached};
_ ->
%% Check max generations
case Config#neuro_config.max_generations of
infinity ->
false;
MaxGen when is_integer(MaxGen), Generation > MaxGen ->
{true, max_generations_reached};
_ ->
false
end
end.
%%% ============================================================================
%%% Internal Functions - Evolution
%%% ============================================================================
%% @private
%% @doc Create next generation from evaluated population.
%%
%% If speciation is enabled, uses species-aware breeding.
%% Otherwise, uses standard elitist selection.
create_next_generation(SortedPopulation, Config, Generation) ->
case Config#neuro_config.speciation_config of
undefined ->
create_next_generation_standard(SortedPopulation, Config, Generation);
#speciation_config{enabled = false} ->
create_next_generation_standard(SortedPopulation, Config, Generation);
SpecConfig ->
create_next_generation_speciated(SortedPopulation, Config, SpecConfig, Generation)
end.
%% @private Standard (non-speciated) next generation creation.
create_next_generation_standard(SortedPopulation, Config, Generation) ->
PopSize = Config#neuro_config.population_size,
SelectionRatio = Config#neuro_config.selection_ratio,
%% Select survivors
NumSurvivors = round(PopSize * SelectionRatio),
Survivors = neuroevolution_selection:top_n(SortedPopulation, NumSurvivors),
Eliminated = lists:nthtail(NumSurvivors, SortedPopulation),
%% Breed offspring
NumOffspring = PopSize - NumSurvivors,
{Offspring, BreedingEvents} = breed_offspring(Survivors, Config, Generation, NumOffspring),
%% Reset survivor stats for next generation
ResetSurvivors = lists:map(
fun(Ind) ->
Ind#individual{
fitness = 0.0,
metrics = #{},
is_survivor = true,
is_offspring = false
}
end,
Survivors
),
Results = #{
survivors => Survivors,
eliminated => Eliminated,
offspring => Offspring
},
{ResetSurvivors ++ Offspring, Results, BreedingEvents}.
%% @private
breed_offspring(Survivors, Config, Generation, Count) ->
breed_offspring(Survivors, Config, Generation, Count, [], []).
breed_offspring(_Survivors, _Config, _Generation, 0, Offspring, Events) ->
{lists:reverse(Offspring), lists:reverse(Events)};
breed_offspring(Survivors, Config, Generation, Remaining, Offspring, Events) ->
{Parent1, Parent2} = neuroevolution_selection:select_parents(Survivors, Config),
Child = neuroevolution_genetic:create_offspring(
Parent1, Parent2, Config, Generation + 1
),
Event = #breeding_event{
parent1_id = Parent1#individual.id,
parent2_id = Parent2#individual.id,
child_id = Child#individual.id,
generation = Generation + 1
},
breed_offspring(Survivors, Config, Generation, Remaining - 1,
[Child | Offspring], [Event | Events]).
%%% ============================================================================
%%% Internal Functions - Speciated Evolution
%%% ============================================================================
%% @private Speciated next generation creation.
%%
%% 1. Assign individuals to species based on genetic compatibility
%% 2. Calculate offspring quota per species (fitness sharing)
%% 3. Select within each species
%% 4. Breed within species (mostly)
%% 5. Eliminate stagnant species
create_next_generation_speciated(SortedPopulation, Config, SpecConfig, Generation) ->
PopSize = Config#neuro_config.population_size,
%% Get current species from process dictionary or start fresh
%% (In production, this should be in State, but we need to pass it through)
CurrentSpecies = get_current_species(),
%% Step 1: Assign all individuals to species
{Species, SpeciesEvents, _NextId} = neuroevolution_speciation:speciate(
SortedPopulation, CurrentSpecies, SpecConfig
),
%% Step 2: Update species fitness statistics
UpdatedSpecies = neuroevolution_speciation:update_species_fitness(Species, SortedPopulation),
%% Step 3: Calculate offspring quotas based on relative fitness
NumOffspring = PopSize - count_survivors(UpdatedSpecies, SpecConfig),
SpeciesWithQuotas = neuroevolution_speciation:calculate_offspring_quotas(
UpdatedSpecies, NumOffspring
),
%% Step 4: Eliminate stagnant species
{ActiveSpecies, ExtinctEvents} = neuroevolution_speciation:eliminate_stagnant_species(
SpeciesWithQuotas, SpecConfig, Generation
),
%% Step 5: Build population map for breeding
PopMap = maps:from_list([{I#individual.id, I} || I <- SortedPopulation]),
%% Step 6: Select and breed within each species
{AllSurvivors, AllEliminated, AllOffspring, BreedingEvents} =
breed_all_species(ActiveSpecies, PopMap, Config, SpecConfig, Generation),
%% Store updated species for next generation
put_current_species(ActiveSpecies),
%% Log species info
error_logger:info_msg(
"[neuroevolution_server] Gen ~p: ~p species, events: ~p~n",
[Generation, length(ActiveSpecies), length(SpeciesEvents ++ ExtinctEvents)]
),
Results = #{
survivors => AllSurvivors,
eliminated => AllEliminated,
offspring => AllOffspring,
species => neuroevolution_speciation:species_summary(ActiveSpecies),
species_events => SpeciesEvents ++ ExtinctEvents
},
{reset_individuals(AllSurvivors) ++ AllOffspring, Results, BreedingEvents}.
%% @private Count total survivors across all species.
count_survivors(Species, SpecConfig) ->
Elitism = SpecConfig#speciation_config.species_elitism,
lists:sum([
max(1, round(length(S#species.members) * Elitism))
|| S <- Species
]).
%% @private Breed all species and collect results.
breed_all_species(Species, PopMap, Config, SpecConfig, Generation) ->
lists:foldl(
fun(Spec, {SurvAcc, ElimAcc, OffAcc, EventAcc}) ->
{Surv, Elim, Off, Events} = breed_single_species(
Spec, Species, PopMap, Config, SpecConfig, Generation
),
{SurvAcc ++ Surv, ElimAcc ++ Elim, OffAcc ++ Off, EventAcc ++ Events}
end,
{[], [], [], []},
Species
).
%% @private Breed a single species.
breed_single_species(Spec, AllSpecies, PopMap, Config, SpecConfig, Generation) ->
%% Get actual individuals for this species
Members = [maps:get(Id, PopMap, undefined) || Id <- Spec#species.members],
ValidMembers = [M || M <- Members, M =/= undefined],
%% Sort by fitness
Sorted = lists:sort(
fun(A, B) -> A#individual.fitness >= B#individual.fitness end,
ValidMembers
),
%% Select survivors (elitism within species)
NumSurvivors = max(1, round(length(Sorted) * SpecConfig#speciation_config.species_elitism)),
Survivors = lists:sublist(Sorted, NumSurvivors),
Eliminated = lists:nthtail(min(NumSurvivors, length(Sorted)), Sorted),
%% Breed offspring for this species
NumOffspring = Spec#species.offspring_quota,
{Offspring, Events} = breed_species_offspring(
Survivors, AllSpecies, PopMap, Config, SpecConfig, Generation, NumOffspring
),
{Survivors, Eliminated, Offspring, Events}.
%% @private Breed offspring for a single species.
breed_species_offspring(_Survivors, _AllSpecies, _PopMap, _Config, _SpecConfig, _Generation, 0) ->
{[], []};
breed_species_offspring(Survivors, AllSpecies, PopMap, Config, SpecConfig, Generation, Count) ->
breed_species_offspring(Survivors, AllSpecies, PopMap, Config, SpecConfig, Generation, Count, [], []).
breed_species_offspring(_Survivors, _AllSpecies, _PopMap, _Config, _SpecConfig, _Generation, 0, Offspring, Events) ->
{lists:reverse(Offspring), lists:reverse(Events)};
breed_species_offspring(Survivors, AllSpecies, PopMap, Config, SpecConfig, Generation, Remaining, Offspring, Events) ->
%% Select parents (mostly within species)
{P1Id, P2Id} = select_species_parents(Survivors, AllSpecies, PopMap, SpecConfig),
%% Get actual individuals
Parent1 = case P1Id of
undefined -> hd(Survivors);
_ -> maps:get(P1Id, PopMap, hd(Survivors))
end,
Parent2 = case P2Id of
undefined -> hd(Survivors);
_ -> maps:get(P2Id, PopMap, hd(Survivors))
end,
%% Create offspring
Child = neuroevolution_genetic:create_offspring(
Parent1, Parent2, Config, Generation + 1
),
Event = #breeding_event{
parent1_id = Parent1#individual.id,
parent2_id = Parent2#individual.id,
child_id = Child#individual.id,
generation = Generation + 1
},
breed_species_offspring(Survivors, AllSpecies, PopMap, Config, SpecConfig, Generation,
Remaining - 1, [Child | Offspring], [Event | Events]).
%% @private Select parents for species breeding.
select_species_parents(Survivors, AllSpecies, _PopMap, SpecConfig) ->
case Survivors of
[] ->
{undefined, undefined};
[Single] ->
{Single#individual.id, Single#individual.id};
_ ->
P1 = lists:nth(rand:uniform(length(Survivors)), Survivors),
%% Check for interspecies mating
case rand:uniform() < SpecConfig#speciation_config.interspecies_mating_rate of
true ->
%% Find a partner from another species
OtherMembers = lists:flatten([
S#species.members
|| S <- AllSpecies,
not lists:member(P1#individual.id, S#species.members)
]),
case OtherMembers of
[] ->
P2 = select_different_parent(P1, Survivors),
{P1#individual.id, P2#individual.id};
_ ->
P2Id = lists:nth(rand:uniform(length(OtherMembers)), OtherMembers),
{P1#individual.id, P2Id}
end;
false ->
P2 = select_different_parent(P1, Survivors),
{P1#individual.id, P2#individual.id}
end
end.
%% @private Select a different parent from the list.
select_different_parent(Parent1, Survivors) ->
select_different_parent(Parent1, Survivors, 3).
select_different_parent(Parent1, _Survivors, 0) ->
Parent1;
select_different_parent(Parent1, Survivors, Retries) ->
Candidate = lists:nth(rand:uniform(length(Survivors)), Survivors),
case Candidate#individual.id =:= Parent1#individual.id of
true -> select_different_parent(Parent1, Survivors, Retries - 1);
false -> Candidate
end.
%% @private Reset individuals for next generation.
reset_individuals(Individuals) ->
[I#individual{
fitness = 0.0,
metrics = #{},
is_survivor = true,
is_offspring = false
} || I <- Individuals].
%% @private Get current species from process dictionary.
get_current_species() ->
case get(current_species) of
undefined -> [];
Species -> Species
end.
%% @private Store current species in process dictionary.
put_current_species(Species) ->
put(current_species, Species).
%%% ============================================================================
%%% Internal Functions - Stats & Events
%%% ============================================================================
%% @private
build_stats(State) ->
Config = State#neuro_state.config,
%% Get species data
SpeciesData = case get_current_species() of
[] -> [];
Species -> neuroevolution_speciation:species_summary(Species)
end,
%% Check if speciation is enabled
SpeciationEnabled = case Config#neuro_config.speciation_config of
undefined -> false;
#speciation_config{enabled = E} -> E
end,
#{
generation => State#neuro_state.generation,
population_size => Config#neuro_config.population_size,
evaluations_per_individual => Config#neuro_config.evaluations_per_individual,
games_completed => State#neuro_state.games_completed,
total_games => State#neuro_state.total_games,
best_fitness_ever => State#neuro_state.best_fitness_ever,
running => State#neuro_state.running,
evaluating => State#neuro_state.evaluating,
last_gen_best => State#neuro_state.last_gen_best,
last_gen_avg => State#neuro_state.last_gen_avg,
generation_history => State#neuro_state.generation_history,
breeding_events => convert_breeding_events(State#neuro_state.breeding_events),
last_gen_results => State#neuro_state.last_gen_results,
%% Species data
speciation_enabled => SpeciationEnabled,
species => SpeciesData,
species_count => length(SpeciesData)
}.
%% @private Convert breeding_event records to maps for easier consumption
convert_breeding_events(Events) ->
[#{
parent1_id => E#breeding_event.parent1_id,
parent2_id => E#breeding_event.parent2_id,
child_id => E#breeding_event.child_id,
generation => E#breeding_event.generation
} || E <- Events].
%% @private
%% @doc Notify event via callback and/or publish to event system.
notify_event(#neuro_state{config = Config}, Event) ->
%% Legacy callback-based notification
notify_event_callback(Config, Event),
%% New event publishing (if enabled)
maybe_publish_event(Config, Event).
%% @private
%% @doc Call the legacy event_handler callback if configured.
notify_event_callback(Config, Event) ->
case Config#neuro_config.event_handler of
undefined ->
ok;
{Module, InitArg} ->
try
Module:handle_event(Event, InitArg)
catch
Class:Reason ->
error_logger:error_msg(
"[neuroevolution_server] Event handler error: ~p:~p~n",
[Class, Reason]
)
end
end.
%% @private
%% @doc Publish event to the event system if enabled.
maybe_publish_event(Config, Event) ->
case Config#neuro_config.publish_events of
true ->
Realm = Config#neuro_config.realm,
Topic = neuroevolution_events:events_topic(Realm),
EventMap = event_to_map(Event, Realm),
neuroevolution_events:publish(Topic, EventMap);
false ->
ok
end.
%% @private
%% @doc Convert internal event tuple to map for publishing.
event_to_map({generation_started, Generation}, Realm) ->
{generation_started, #{
realm => Realm,
generation => Generation,
timestamp => erlang:system_time(millisecond)
}};
event_to_map({evaluation_progress, Generation, Completed, Total}, Realm) ->
{evaluation_progress, #{
realm => Realm,
generation => Generation,
completed => Completed,
total => Total,
timestamp => erlang:system_time(millisecond)
}};
event_to_map({generation_complete, GenStats}, Realm) ->
{generation_completed, #{
realm => Realm,
generation => GenStats#generation_stats.generation,
best_fitness => GenStats#generation_stats.best_fitness,
avg_fitness => GenStats#generation_stats.avg_fitness,
worst_fitness => GenStats#generation_stats.worst_fitness,
best_individual_id => GenStats#generation_stats.best_individual_id,
timestamp => erlang:system_time(millisecond)
}};
event_to_map({training_started, _Config}, Realm) ->
{training_started, #{
realm => Realm,
timestamp => erlang:system_time(millisecond)
}};
event_to_map({training_stopped, Generation}, Realm) ->
{training_stopped, #{
realm => Realm,
generation => Generation,
timestamp => erlang:system_time(millisecond)
}};
event_to_map(Event, Realm) ->
%% Fallback for other events
{other, #{realm => Realm, event => Event, timestamp => erlang:system_time(millisecond)}}.
%%% ============================================================================
%%% Internal Functions - Meta-Controller Integration
%%% ============================================================================
%% @private
%% @doc Start meta-controller if configured.
maybe_start_meta_controller(Config) ->
case Config#neuro_config.meta_controller_config of
undefined ->
undefined;
MetaConfig ->
case meta_controller:start_link(MetaConfig) of
{ok, Pid} ->
error_logger:info_msg(
"[neuroevolution_server] Started meta-controller ~p~n",
[Pid]
),
_ = meta_controller:start_training(Pid),
Pid;
{error, Reason} ->
error_logger:error_msg(
"[neuroevolution_server] Failed to start meta-controller: ~p~n",
[Reason]
),
undefined
end
end.
%% @private
%% @doc Update meta-controller with generation stats and get new hyperparameters.
maybe_update_meta_controller(GenStats, Config, State) ->
case State#neuro_state.meta_controller of
undefined ->
{Config, State};
MetaPid ->
try
%% Get new hyperparameters from meta-controller
NewParams = meta_controller:update(MetaPid, GenStats),
%% Apply new parameters to config
UpdatedConfig = apply_meta_params(Config, NewParams),
%% Log parameter changes
log_param_changes(Config, UpdatedConfig, State#neuro_state.generation),
{UpdatedConfig, State}
catch
Class:Reason ->
error_logger:error_msg(
"[neuroevolution_server] Meta-controller update failed: ~p:~p~n",
[Class, Reason]
),
{Config, State}
end
end.
%% @private
%% @doc Apply meta-controller parameters to neuro_config (including speciation).
apply_meta_params(Config, NewParams) ->
%% Core evolution parameters
MutationRate = maps:get(mutation_rate, NewParams, Config#neuro_config.mutation_rate),
MutationStrength = maps:get(mutation_strength, NewParams, Config#neuro_config.mutation_strength),
SelectionRatio = maps:get(selection_ratio, NewParams, Config#neuro_config.selection_ratio),
%% Get current speciation config (or create default if undefined)
OldSpecConfig = case Config#neuro_config.speciation_config of
undefined -> #speciation_config{enabled = false};
SC -> SC
end,
%% Apply LC-controlled speciation parameters
NewSpecConfig = OldSpecConfig#speciation_config{
compatibility_threshold = maps:get(compatibility_threshold, NewParams,
OldSpecConfig#speciation_config.compatibility_threshold),
target_species = maps:get(target_species, NewParams,
OldSpecConfig#speciation_config.target_species),
min_species_size = maps:get(min_species_size, NewParams,
OldSpecConfig#speciation_config.min_species_size),
max_stagnation = maps:get(max_stagnation, NewParams,
OldSpecConfig#speciation_config.max_stagnation),
species_elitism = maps:get(species_elitism, NewParams,
OldSpecConfig#speciation_config.species_elitism),
interspecies_mating_rate = maps:get(interspecies_mating_rate, NewParams,
OldSpecConfig#speciation_config.interspecies_mating_rate)
},
Config#neuro_config{
mutation_rate = MutationRate,
mutation_strength = MutationStrength,
selection_ratio = SelectionRatio,
speciation_config = NewSpecConfig
}.
%% @private
%% @doc Get speciation config, using default if undefined.
-dialyzer({no_match, get_speciation_config/1}).
get_speciation_config(undefined) -> #speciation_config{};
get_speciation_config(Config) -> Config.
%% @private
%% @doc Log significant parameter changes.
log_param_changes(OldConfig, NewConfig, Generation) ->
OldMR = OldConfig#neuro_config.mutation_rate,
NewMR = NewConfig#neuro_config.mutation_rate,
OldMS = OldConfig#neuro_config.mutation_strength,
NewMS = NewConfig#neuro_config.mutation_strength,
OldSR = OldConfig#neuro_config.selection_ratio,
NewSR = NewConfig#neuro_config.selection_ratio,
%% Log if any parameter changed by more than 10%
Threshold = 0.10,
Changes = [
{mutation_rate, OldMR, NewMR} || abs(NewMR - OldMR) / max(0.01, OldMR) > Threshold
] ++ [
{mutation_strength, OldMS, NewMS} || abs(NewMS - OldMS) / max(0.01, OldMS) > Threshold
] ++ [
{selection_ratio, OldSR, NewSR} || abs(NewSR - OldSR) / max(0.01, OldSR) > Threshold
],
case Changes of
[] -> ok;
_ ->
error_logger:info_msg(
"[neuroevolution_server] Gen ~p: Meta-controller adjusted params: ~p~n",
[Generation, Changes]
)
end.