%% @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: %%
    %%
  1. Evaluate all individuals in parallel using the configured evaluator
  2. %%
  3. Calculate fitness for each individual
  4. %%
  5. Select survivors (top performers)
  6. %%
  7. Breed survivors to create offspring via crossover + mutation
  8. %%
  9. Form new generation from survivors and offspring
  10. %%
  11. Repeat
  12. %%
%% %% == Configuration == %% %% The server is configured via a `#neuro_config{}' record that specifies: %% %% %% == 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.