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.
%%
%% == Pluggable Evolution Strategies ==
%%
%% The server delegates all evolution logic to a configurable strategy module
%% that implements the `evolution_strategy' behaviour. Built-in strategies:
%%
%% - `generational_strategy' - Traditional (mu,lambda) batch evolution (default)
%% - `steady_state_strategy' - Continuous replacement, no generations
%% - `island_strategy' - Parallel populations with migration
%% - `novelty_strategy' - Behavioral novelty search
%% - `map_elites_strategy' - Quality-diversity with niche grid
%%
%% == Generation Lifecycle ==
%%
%% Each generation follows this cycle:
%% <ol>
%% <li>Evaluate all individuals in parallel using the configured evaluator</li>
%% <li>Strategy receives evaluation results and manages selection/breeding</li>
%% <li>Strategy emits lifecycle events (individual_born, individual_died, etc.)</li>
%% <li>Server orchestrates next evaluation round</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>Strategy module (implements `evolution_strategy' behaviour)</li>
%% <li>Optional event handler for notifications</li>
%% </ul>
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(neuroevolution_server).
-behaviour(gen_server).
-include("neuroevolution.hrl").
-include("evolution_strategy.hrl").
-include("lifecycle_events.hrl").
%% API
-export([
start_link/1,
start_link/2,
start_training/1,
stop_training/1,
get_stats/1,
get_population/1,
get_population_snapshot/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 (raw individuals).
-spec get_population(ServerRef) -> {ok, [individual()]} when
ServerRef :: pid() | atom().
get_population(ServerRef) ->
gen_server:call(ServerRef, get_population).
%% @doc Get population snapshot from the strategy.
-spec get_population_snapshot(ServerRef) -> {ok, population_snapshot()} when
ServerRef :: pid() | atom().
get_population_snapshot(ServerRef) ->
gen_server:call(ServerRef, get_population_snapshot).
%% @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.
-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]
),
%% Get strategy module (default to generational_strategy)
StrategyModule = get_strategy_module(Config),
%% Build strategy config
%% Merge strategy_params into top-level so strategy can access options like network_factory
StrategyParams = get_strategy_params(Config),
StrategyConfig = maps:merge(StrategyParams, #{
neuro_config => Config,
strategy_params => StrategyParams
}),
%% Initialize strategy
{ok, StrategyState, InitEvents} = evolution_strategy:init(StrategyModule, StrategyConfig),
%% Process initial events (birth events for initial population)
process_lifecycle_events(InitEvents, Config),
%% Get population from strategy for state
Snapshot = evolution_strategy:get_population_snapshot(StrategyModule, StrategyState),
Population = extract_population_from_snapshot(Snapshot, StrategyState),
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,
strategy_module = StrategyModule,
strategy_state = StrategyState
},
{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(get_population_snapshot, _From, State) ->
StrategyModule = State#neuro_state.strategy_module,
StrategyState = State#neuro_state.strategy_state,
Snapshot = evolution_strategy:get_population_snapshot(StrategyModule, StrategyState),
{reply, {ok, Snapshot}, State};
handle_call({update_config, Params}, _From, State) ->
%% Apply external meta-controller (LC) parameters via strategy
StrategyModule = State#neuro_state.strategy_module,
StrategyState = State#neuro_state.strategy_state,
%% Update strategy state with new params
NewStrategyState = evolution_strategy:apply_meta_params(StrategyModule, Params, StrategyState),
%% Also update neuro_config for consistency
Config = State#neuro_state.config,
UpdatedConfig = apply_config_params(Config, Params),
%% Return the current config values as confirmation
ConfigMap = build_config_response(UpdatedConfig),
NewState = State#neuro_state{
config = UpdatedConfig,
strategy_state = NewStrategyState
},
{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 - Strategy Integration
%%% ============================================================================
%% @private Get strategy module from config.
get_strategy_module(Config) ->
case Config#neuro_config.strategy_config of
undefined ->
generational_strategy;
#strategy_config{strategy_module = Module} when Module =/= undefined ->
Module;
_ ->
generational_strategy
end.
%% @private Get strategy params from config.
get_strategy_params(Config) ->
case Config#neuro_config.strategy_config of
undefined ->
#{};
#strategy_config{strategy_params = Params} ->
Params
end.
%% @private Extract population list from strategy snapshot.
%% The strategy state contains the actual population.
extract_population_from_snapshot(_Snapshot, StrategyState) ->
%% Access internal population from strategy state
%% This is strategy-specific; generational_strategy uses #gen_state{}
case StrategyState of
{gen_state, _, _, _, Population, _, _, _, _, _, _, _, _, _, _, _} ->
Population;
_ ->
%% Fallback: try to get from state if it's a record
try
element(5, StrategyState) % Population is usually 5th element in gen_state
catch
_:_ -> []
end
end.
%% @private Process lifecycle events from strategy.
process_lifecycle_events(Events, Config) ->
lists:foreach(
fun(Event) ->
maybe_publish_lifecycle_event(Config, Event)
end,
Events
).
%% @private Publish a lifecycle event if event publishing is enabled.
maybe_publish_lifecycle_event(Config, Event) ->
case Config#neuro_config.publish_events of
true ->
Realm = Config#neuro_config.realm,
Topic = neuroevolution_events:events_topic(Realm),
EventMap = lifecycle_event_to_map(Event, Realm),
neuroevolution_events:publish(Topic, EventMap);
false ->
ok
end.
%% @private Convert lifecycle event to map for publishing.
lifecycle_event_to_map(#individual_born{id = Id, origin = Origin, parent_ids = Parents}, Realm) ->
{individual_born, #{
realm => Realm,
individual_id => Id,
origin => Origin,
parent_ids => Parents,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(#individual_died{id = Id, reason = Reason, final_fitness = Fitness}, Realm) ->
{individual_died, #{
realm => Realm,
individual_id => Id,
reason => Reason,
final_fitness => Fitness,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(#individual_evaluated{id = Id, fitness = Fitness, metrics = Metrics}, Realm) ->
{individual_evaluated, #{
realm => Realm,
individual_id => Id,
fitness => Fitness,
metrics => Metrics,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(#cohort_evaluated{generation = Gen, best_fitness = Best, avg_fitness = Avg}, Realm) ->
{cohort_evaluated, #{
realm => Realm,
generation => Gen,
best_fitness => Best,
avg_fitness => Avg,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(#breeding_complete{generation = Gen, survivor_count = Surv, offspring_count = Off}, Realm) ->
{breeding_complete, #{
realm => Realm,
generation => Gen,
survivor_count => Surv,
offspring_count => Off,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(#generation_advanced{generation = Gen, previous_best_fitness = Best}, Realm) ->
{generation_advanced, #{
realm => Realm,
generation => Gen,
previous_best_fitness => Best,
timestamp => erlang:system_time(millisecond)
}};
lifecycle_event_to_map(Event, Realm) ->
{lifecycle_event, #{
realm => Realm,
event => Event,
timestamp => erlang:system_time(millisecond)
}}.
%%% ============================================================================
%%% Internal Functions - Evaluation
%%% ============================================================================
%% @private
%% @doc Calculate max concurrent evaluations based on config or system defaults.
get_max_concurrent(Config) ->
case Config#neuro_config.max_concurrent_evaluations of
undefined ->
erlang:system_info(schedulers_online) * 2;
N when is_integer(N), N > 0 ->
N
end.
%% @private
%% @doc Evaluate all individuals with bounded concurrency.
evaluate_population_parallel(Population, Config, ServerPid, Generation) ->
MaxConcurrent = get_max_concurrent(Config),
EvaluatorModule = Config#neuro_config.evaluator_module,
EvaluatorOptions = Config#neuro_config.evaluator_options,
GamesPerIndividual = Config#neuro_config.evaluations_per_individual,
PopSize = length(Population),
Options = maps:merge(EvaluatorOptions, #{
games => GamesPerIndividual,
notify_pid => ServerPid
}),
EventCtx = #{
config => Config,
generation => Generation,
total => PopSize
},
evaluate_batches(Population, MaxConcurrent, EvaluatorModule, Options, [], EventCtx, 0).
%% @private
evaluate_batches([], _MaxConcurrent, _EvaluatorModule, _Options, Acc, _EventCtx, _Completed) ->
lists:reverse(Acc);
evaluate_batches(Population, MaxConcurrent, EvaluatorModule, Options, Acc, EventCtx, Completed) ->
{Batch, Remaining} = split_list(Population, MaxConcurrent),
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,
Batch
),
{BatchResults, NewCompleted} = collect_eval_results(Refs, [], EventCtx, Completed),
evaluate_batches(Remaining, MaxConcurrent, EvaluatorModule, Options,
lists:reverse(BatchResults) ++ Acc, EventCtx, NewCompleted).
%% @private
split_list(List, N) when N >= length(List) ->
{List, []};
split_list(List, N) ->
lists:split(N, List).
%% @private
collect_eval_results([], Acc, _EventCtx, Completed) ->
{lists:reverse(Acc), Completed};
collect_eval_results(Refs, Acc, EventCtx, Completed) ->
Timeout = 30000,
Total = maps:get(total, EventCtx, 0),
receive
{eval_result, Ref, {ok, EvaluatedIndividual}} ->
NewRefs = lists:keydelete(Ref, 1, Refs),
NewCompleted = Completed + 1,
notify_individual_evaluated(EventCtx, EvaluatedIndividual, NewCompleted, Total),
collect_eval_results(NewRefs, [EvaluatedIndividual | Acc], EventCtx, NewCompleted);
{eval_result, Ref, {error, _Reason}} ->
case lists:keyfind(Ref, 1, Refs) of
{Ref, Original} ->
NewRefs = lists:keydelete(Ref, 1, Refs),
NewCompleted = Completed + 1,
collect_eval_results(NewRefs, [Original | Acc], EventCtx, NewCompleted);
false ->
collect_eval_results(Refs, Acc, EventCtx, Completed)
end
after Timeout ->
error_logger:warning_msg(
"[neuroevolution_server] Evaluation timeout - ~p pending~n",
[length(Refs)]
),
Remaining = [Ind || {_, Ind} <- Refs],
{lists:reverse(Acc) ++ Remaining, Completed + length(Remaining)}
end.
%% @private
notify_individual_evaluated(EventCtx, Individual, Completed, Total) ->
Config = maps:get(config, EventCtx),
case Config#neuro_config.publish_events of
true ->
Realm = Config#neuro_config.realm,
Topic = neuroevolution_events:events_topic(Realm),
EventData = #{
event_type => individual_evaluated,
individual_id => Individual#individual.id,
fitness => Individual#individual.fitness,
metrics => Individual#individual.metrics,
completed => Completed,
total => Total
},
neuroevolution_events:publish(Topic, EventData);
false ->
ok
end.
%%% ============================================================================
%%% Internal Functions - Direct Evaluation Mode
%%% ============================================================================
%% @private
start_direct_evaluation(Population, State) ->
Config = State#neuro_state.config,
ServerPid = self(),
Generation = State#neuro_state.generation,
spawn_link(fun() ->
Results = evaluate_population_parallel(Population, Config, ServerPid, Generation),
ServerPid ! {evaluation_complete, Results}
end),
State.
%%% ============================================================================
%%% Internal Functions - Distributed Evaluation Mode
%%% ============================================================================
%% @private
start_distributed_evaluation(Population, State) ->
Config = State#neuro_state.config,
Realm = Config#neuro_config.realm,
Timeout = Config#neuro_config.evaluation_timeout,
ResultTopic = neuroevolution_events:evaluated_topic(Realm),
ok = neuroevolution_events:subscribe(ResultTopic),
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
),
TimeoutRef = make_ref(),
erlang:send_after(Timeout, self(), {eval_timeout, TimeoutRef}),
State#neuro_state{
pending_evaluations = PendingEvaluations,
eval_timeout_ref = TimeoutRef
}.
%% @private
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}} ->
Metrics = maps:get(metrics, Result, #{}),
EvaluatedInd = Original#individual{metrics = Metrics},
NewPending = maps:remove(RequestId, PendingEvaluations),
NewState = State#neuro_state{pending_evaluations = NewPending},
case maps:size(NewPending) of
0 ->
finish_distributed_evaluation(NewState, [EvaluatedInd]);
_ ->
AccumulatedResults = get_accumulated_results(State),
NewAccumulated = [EvaluatedInd | AccumulatedResults],
{noreply, store_accumulated_results(NewState, NewAccumulated)}
end;
error ->
{noreply, State}
end.
%% @private
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]
),
PendingIndividuals = [Ind || {_IndId, Ind} <- maps:values(PendingEvaluations)],
AccumulatedResults = get_accumulated_results(State),
AllResults = AccumulatedResults ++ PendingIndividuals,
finish_distributed_evaluation(State, AllResults).
%% @private
finish_distributed_evaluation(State, AdditionalResults) ->
Config = State#neuro_state.config,
Realm = Config#neuro_config.realm,
ResultTopic = neuroevolution_events:evaluated_topic(Realm),
ok = neuroevolution_events:unsubscribe(ResultTopic),
AccumulatedResults = get_accumulated_results(State),
AllResults = AccumulatedResults ++ AdditionalResults,
CleanState = State#neuro_state{
pending_evaluations = #{},
eval_timeout_ref = undefined
},
CleanState2 = clear_accumulated_results(CleanState),
handle_evaluation_complete(CleanState2, AllResults).
%% @private
get_accumulated_results(#neuro_state{}) ->
case get(distributed_eval_results) of
undefined -> [];
Results -> Results
end.
%% @private
store_accumulated_results(State, Results) ->
put(distributed_eval_results, Results),
State.
%% @private
clear_accumulated_results(State) ->
erase(distributed_eval_results),
State.
%%% ============================================================================
%%% Internal Functions - Generation Complete (Strategy Delegation)
%%% ============================================================================
%% @private
handle_evaluation_complete(State, EvaluatedPopulation) ->
Config = State#neuro_state.config,
StrategyModule = State#neuro_state.strategy_module,
StrategyState = State#neuro_state.strategy_state,
%% Calculate fitness for all individuals
Population = lists:map(
fun(Ind) ->
Fitness = calculate_fitness(Ind, Config),
Ind#individual{fitness = Fitness}
end,
EvaluatedPopulation
),
%% Notify population_evaluated (before strategy processes results)
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]
),
PopulationSummaryForEvent = [summarize_individual_for_grid(I, false, false) || I <- Sorted],
notify_event(State, {population_evaluated, #{
generation => State#neuro_state.generation,
population => PopulationSummaryForEvent,
best_fitness => Best#individual.fitness,
avg_fitness => AvgFitness,
worst_fitness => (lists:last(Sorted))#individual.fitness,
population_size => length(Sorted)
}}),
%% Feed each evaluation result to the strategy
{FinalStrategyState, AllEvents, AllActions} = lists:foldl(
fun(Ind, {StratState, Events, Actions}) ->
FitnessResult = #{fitness => Ind#individual.fitness, metrics => Ind#individual.metrics},
{NewActions, NewEvents, NewStratState} = evolution_strategy:handle_evaluation_result(
StrategyModule, Ind#individual.id, FitnessResult, StratState
),
{NewStratState, Events ++ NewEvents, Actions ++ NewActions}
end,
{StrategyState, [], []},
Sorted
),
%% Process lifecycle events from strategy
process_lifecycle_events(AllEvents, Config),
%% Process actions from strategy
NextPopulation = process_strategy_actions(AllActions, FinalStrategyState, StrategyModule),
%% Update meta-controller if configured
GenStats = build_generation_stats(State, Sorted),
{UpdatedConfig, NewState1} = maybe_update_meta_controller(GenStats, Config, State),
%% Notify generation complete
MetaParams = #{
mutation_rate => UpdatedConfig#neuro_config.mutation_rate,
mutation_strength => UpdatedConfig#neuro_config.mutation_strength,
selection_ratio => UpdatedConfig#neuro_config.selection_ratio,
population_size => UpdatedConfig#neuro_config.population_size
},
notify_event(NewState1, {generation_complete, #{
generation_stats => GenStats,
meta_params => MetaParams
}}),
%% 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)],
strategy_state = FinalStrategyState,
evaluating = false,
games_completed = 0
},
%% Check stopping conditions and continue if appropriate
case should_stop(NewState) of
{true, Reason} ->
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 Process strategy actions and extract next population.
process_strategy_actions(Actions, StrategyState, StrategyModule) ->
%% Look for evaluate_batch action which indicates new population ready
case lists:keyfind(evaluate_batch, 1, Actions) of
{evaluate_batch, _IndIds} ->
%% Get updated population from strategy snapshot
Snapshot = evolution_strategy:get_population_snapshot(StrategyModule, StrategyState),
extract_population_from_snapshot(Snapshot, StrategyState);
false ->
%% No new population action, keep current (shouldn't happen normally)
extract_population_from_snapshot(
evolution_strategy:get_population_snapshot(StrategyModule, StrategyState),
StrategyState
)
end.
%% @private Build generation stats for meta-controller.
build_generation_stats(State, Sorted) ->
Best = hd(Sorted),
AvgFitness = neuroevolution_stats:avg_fitness(Sorted),
#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,
population_size = length(Sorted)
}.
%% @private
calculate_fitness(Individual, Config) ->
EvaluatorModule = Config#neuro_config.evaluator_module,
Metrics = Individual#individual.metrics,
try
EvaluatorModule:calculate_fitness(Metrics)
catch
error:undef ->
neuroevolution_evaluator:default_fitness(Metrics)
end.
%% @private
-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}) ->
case Config#neuro_config.target_fitness of
TargetFitness when is_number(TargetFitness), BestFitness >= TargetFitness ->
{true, target_fitness_reached};
_ ->
case Config#neuro_config.max_generations of
infinity ->
false;
MaxGen when is_integer(MaxGen), Generation > MaxGen ->
{true, max_generations_reached};
_ ->
false
end
end.
%% @private
summarize_individual_for_grid(Individual, IsSurvivor, IsOffspring) ->
#{
id => Individual#individual.id,
index => Individual#individual.id,
fitness => Individual#individual.fitness,
is_survivor => IsSurvivor,
is_offspring => IsOffspring,
wins => maps:get(wins, Individual#individual.metrics, undefined)
}.
%%% ============================================================================
%%% Internal Functions - Stats & Events
%%% ============================================================================
%% @private
build_stats(State) ->
Config = State#neuro_state.config,
StrategyModule = State#neuro_state.strategy_module,
StrategyState = State#neuro_state.strategy_state,
%% Get population snapshot from strategy
Snapshot = evolution_strategy:get_population_snapshot(StrategyModule, StrategyState),
#{
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,
%% Strategy-provided data
strategy_module => StrategyModule,
population_snapshot => Snapshot,
species_count => maps:get(species_count, Snapshot, 0)
}.
%% @private
notify_event(#neuro_state{config = Config}, Event) ->
notify_event_callback(Config, Event),
maybe_publish_event(Config, Event).
%% @private
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
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
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, Data}, Realm) when is_map(Data) ->
GenStats = maps:get(generation_stats, Data),
{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({population_evaluated, Data}, Realm) when is_map(Data) ->
{population_evaluated, Data#{
realm => Realm,
timestamp => erlang:system_time(millisecond)
}};
event_to_map({training_complete, Data}, Realm) when is_map(Data) ->
{training_complete, Data#{
realm => Realm,
timestamp => erlang:system_time(millisecond)
}};
event_to_map(Event, Realm) ->
{other, #{realm => Realm, event => Event, timestamp => erlang:system_time(millisecond)}}.
%%% ============================================================================
%%% Internal Functions - Meta-Controller Integration
%%% ============================================================================
%% @private
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
maybe_update_meta_controller(GenStats, Config, State) ->
case State#neuro_state.meta_controller of
undefined ->
{Config, State};
MetaPid ->
try
NewParams = meta_controller:update(MetaPid, GenStats),
UpdatedConfig = apply_config_params(Config, NewParams),
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 Apply parameters to neuro_config.
apply_config_params(Config, NewParams) ->
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),
Config#neuro_config{
mutation_rate = MutationRate,
mutation_strength = MutationStrength,
selection_ratio = SelectionRatio
}.
%% @private Build response map for update_config.
build_config_response(Config) ->
#{
mutation_rate => Config#neuro_config.mutation_rate,
mutation_strength => Config#neuro_config.mutation_strength,
selection_ratio => Config#neuro_config.selection_ratio
}.
%% @private
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,
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.