%% @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: %%
    %%
  1. Evaluate all individuals in parallel using the configured evaluator
  2. %%
  3. Strategy receives evaluation results and manages selection/breeding
  4. %%
  5. Strategy emits lifecycle events (individual_born, individual_died, etc.)
  6. %%
  7. Server orchestrates next evaluation round
  8. %%
  9. Repeat
  10. %%
%% %% == Configuration == %% %% The server is configured via a `#neuro_config{}' record that specifies: %% %% %% @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.