%% @doc LTC-based meta-controller for adaptive hyperparameter optimization. %% %% This gen_server implements a meta-learning system that uses Liquid Time-Constant %% (LTC) neural networks to dynamically control neuroevolution hyperparameters. %% %% == Architecture == %% %% The meta-controller operates at a higher timescale than task networks. %% It receives training metrics as inputs and outputs hyperparameters: %% mutation_rate, mutation_strength, and selection_ratio. %% %% == LTC Advantage == %% %% LTC neurons maintain internal state that evolves continuously. %% This enables temporal memory of training dynamics, adaptive response %% speed based on signal magnitude, and smooth parameter transitions. %% %% == Usage == %% %% Create a config and start the meta-controller: %% %% ```erlang %% Config = #meta_config{network_topology = {8, [16, 8], 4}}, %% {ok, Pid} = meta_controller:start_link(Config), %% meta_controller:start_training(Pid), %% NewParams = meta_controller:update(Pid, GenerationStats). %% ''' %% %% @author Macula.io %% @copyright 2025 Macula.io -module(meta_controller). -behaviour(gen_server). -include("neuroevolution.hrl"). -include("meta_controller.hrl"). %% API -export([ start_link/1, start_link/2, start_training/1, stop_training/1, update/2, get_state/1, get_params/1, reset/1 ]). %% 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 meta-controller with given configuration. -spec start_link(meta_config()) -> {ok, pid()} | {error, term()}. start_link(Config) -> start_link(Config, []). %% @doc Start the meta-controller with configuration and options. %% %% Options: %% - `{id, Id}' - Server identifier (default: make_ref()) %% - `{name, Name}' - Register with given name -spec start_link(meta_config(), proplists:proplist()) -> {ok, pid()} | {error, term()}. 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 meta-learning training process. -spec start_training(pid() | atom()) -> {ok, started | already_running}. start_training(ServerRef) -> gen_server:call(ServerRef, start_training). %% @doc Stop the meta-learning training process. -spec stop_training(pid() | atom()) -> ok. stop_training(ServerRef) -> gen_server:call(ServerRef, stop_training). %% @doc Update the meta-controller with new generation stats. %% %% This is the main entry point called after each neuroevolution generation. %% Returns new hyperparameters to use for the next generation. -spec update(pid() | atom(), generation_stats() | map()) -> #{atom() => float()}. update(ServerRef, GenerationStats) -> gen_server:call(ServerRef, {update, GenerationStats}). %% @doc Get current meta-controller state (for visualization). -spec get_state(pid() | atom()) -> {ok, map()}. get_state(ServerRef) -> gen_server:call(ServerRef, get_state). %% @doc Get current hyperparameter values. -spec get_params(pid() | atom()) -> #{atom() => float()}. get_params(ServerRef) -> gen_server:call(ServerRef, get_params). %% @doc Reset the meta-controller to initial state. -spec reset(pid() | atom()) -> ok. reset(ServerRef) -> gen_server:call(ServerRef, reset). %%% ============================================================================ %%% gen_server Callbacks %%% ============================================================================ %% @private init({Id, Config}) -> error_logger:info_msg( "[meta_controller] Initializing with topology ~p, tau=~p~n", [Config#meta_config.network_topology, Config#meta_config.time_constant] ), %% Initialize LTC network {LtcWeights, LtcStates} = initialize_network(Config), %% Initialize output mapping OutputMapping = create_output_mapping(Config), State = #meta_state{ id = Id, config = Config, ltc_weights = LtcWeights, ltc_states = LtcStates, current_params = default_params(Config), param_momentum = #{ mutation_rate => 0.0, mutation_strength => 0.0, selection_ratio => 0.0 } }, %% Store output mapping in process dictionary for efficiency put(output_mapping, OutputMapping), {ok, State}. %% @private handle_call(start_training, _From, State = #meta_state{running = true}) -> {reply, {ok, already_running}, State}; handle_call(start_training, _From, State) -> error_logger:info_msg("[meta_controller] Starting meta-training~n"), NewState = State#meta_state{running = true}, {reply, {ok, started}, NewState}; handle_call(stop_training, _From, State) -> NewState = State#meta_state{running = false}, {reply, ok, NewState}; handle_call({update, GenStats}, _From, State) -> {NewParams, NewState} = process_generation(GenStats, State), {reply, NewParams, NewState}; handle_call(get_state, _From, State) -> StateMap = #{ generation => State#meta_state.generation, current_params => State#meta_state.current_params, cumulative_reward => State#meta_state.cumulative_reward, best_fitness_ever => State#meta_state.best_fitness_ever, stagnation_count => State#meta_state.stagnation_count, running => State#meta_state.running, metrics_history_length => length(State#meta_state.metrics_history), ltc_states => State#meta_state.ltc_states }, {reply, {ok, StateMap}, State}; handle_call(get_params, _From, State) -> {reply, State#meta_state.current_params, State}; handle_call(reset, _From, State) -> Config = State#meta_state.config, {LtcWeights, LtcStates} = initialize_network(Config), NewState = State#meta_state{ ltc_weights = LtcWeights, ltc_states = LtcStates, current_params = default_params(Config), metrics_history = [], generation = 0, cumulative_reward = 0.0, best_fitness_ever = 0.0, stagnation_count = 0 }, {reply, ok, NewState}; handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. %% @private handle_cast(_Msg, State) -> {noreply, State}. %% @private handle_info(_Info, State) -> {noreply, State}. %% @private terminate(_Reason, _State) -> ok. %% @private code_change(_OldVsn, State, _Extra) -> {ok, State}. %%% ============================================================================ %%% Internal Functions - Network Initialization %%% ============================================================================ %% @private Initialize the LTC network structure. initialize_network(Config) -> {InputSize, HiddenLayers, OutputSize} = Config#meta_config.network_topology, Tau = Config#meta_config.time_constant, Bound = Config#meta_config.state_bound, %% Build layer sizes: [InputSize | HiddenLayers] ++ [OutputSize] LayerSizes = [InputSize | HiddenLayers] ++ [OutputSize], %% Initialize weights and states for each layer connection {Weights, States} = initialize_layers(LayerSizes, Tau, Bound, 1, #{}, #{}), {Weights, States}. %% @private Initialize weights between consecutive layers. initialize_layers([_], _Tau, _Bound, _LayerIdx, Weights, States) -> {Weights, States}; initialize_layers([FromSize, ToSize | Rest], Tau, Bound, LayerIdx, Weights, States) -> %% Xavier initialization for weights Scale = math:sqrt(2.0 / (FromSize + ToSize)), %% Initialize neurons in this layer {LayerWeights, LayerStates} = lists:foldl( fun(NeuronIdx, {WAcc, SAcc}) -> NeuronId = {LayerIdx, NeuronIdx}, %% Random weights for each input InputWeights = [{I, random_weight(Scale)} || I <- lists:seq(1, FromSize)], %% Random bias Bias = random_weight(Scale * 0.1), %% Random backbone/head weights for CfC BackboneWeights = [random_weight(0.1) || _ <- lists:seq(1, 3)], HeadWeights = [random_weight(0.1) || _ <- lists:seq(1, 3)], Neuron = #meta_neuron{ id = NeuronId, internal_state = 0.0, time_constant = Tau, state_bound = Bound, input_weights = InputWeights, bias = Bias, backbone_weights = BackboneWeights, head_weights = HeadWeights }, {WAcc#{NeuronId => Neuron}, SAcc#{NeuronId => 0.0}} end, {Weights, States}, lists:seq(1, ToSize) ), initialize_layers([ToSize | Rest], Tau, Bound, LayerIdx + 1, LayerWeights, LayerStates). %% @private Generate random weight with given scale. random_weight(Scale) -> (rand:uniform() * 2.0 - 1.0) * Scale. %% @private Create output mapping for parameters. create_output_mapping(Config) -> BaseParams = [mutation_rate, mutation_strength, selection_ratio], Params = case Config#meta_config.control_population_size of true -> BaseParams ++ [evaluations_per_individual]; false -> BaseParams end, IndexToParam = maps:from_list([{I, lists:nth(I, Params)} || I <- lists:seq(1, length(Params))]), ParamToIndex = maps:from_list([{lists:nth(I, Params), I} || I <- lists:seq(1, length(Params))]), %% All outputs use sigmoid activation for bounded parameters OutputActivations = maps:from_list([{I, sigmoid} || I <- lists:seq(1, length(Params))]), #output_mapping{ index_to_param = IndexToParam, param_to_index = ParamToIndex, output_activations = OutputActivations }. %% @private Default parameter values. default_params(_Config) -> #{ mutation_rate => 0.10, mutation_strength => 0.30, selection_ratio => 0.20 }. %%% ============================================================================ %%% Internal Functions - Generation Processing %%% ============================================================================ %% @private Process a completed generation and compute new parameters. process_generation(GenStats, State) -> Config = State#meta_state.config, %% Convert generation stats to metrics Metrics = stats_to_metrics(GenStats, State), %% Update metrics history NewHistory = update_history(Metrics, State#meta_state.metrics_history, Config), %% Compute input features for LTC network Inputs = compute_input_features(Metrics, NewHistory, State), %% Forward pass through LTC network {Outputs, NewLtcStates} = forward_pass(Inputs, State), %% Convert outputs to parameter values NewParams = outputs_to_params(Outputs, Config), %% Compute reward for this generation Reward = meta_reward:compute(Metrics, NewHistory, Config), %% Update training (gradient estimate) TrainingEvent = #meta_training_event{ generation = State#meta_state.generation + 1, inputs = Inputs, outputs = Outputs, reward = Reward#meta_reward.total, gradients = #{} }, %% Apply momentum smoothing to parameter changes SmoothedParams = apply_momentum(NewParams, State), %% Update stagnation counter NewStagnation = update_stagnation(Metrics, State), NewState = State#meta_state{ generation = State#meta_state.generation + 1, metrics_history = NewHistory, ltc_states = NewLtcStates, current_params = SmoothedParams, cumulative_reward = State#meta_state.cumulative_reward + Reward#meta_reward.total, best_fitness_ever = max(State#meta_state.best_fitness_ever, Metrics#generation_metrics.best_fitness), stagnation_count = NewStagnation }, %% Log progress periodically maybe_log_progress(NewState, TrainingEvent), {SmoothedParams, NewState}. %% @private Convert generation stats to metrics record. stats_to_metrics(GenStats, State) when is_record(GenStats, generation_stats) -> PrevMetrics = case State#meta_state.metrics_history of [] -> undefined; [H | _] -> H end, BestFitness = GenStats#generation_stats.best_fitness, AvgFitness = GenStats#generation_stats.avg_fitness, WorstFitness = GenStats#generation_stats.worst_fitness, FitnessDelta = case PrevMetrics of undefined -> 0.0; _ -> BestFitness - PrevMetrics#generation_metrics.best_fitness end, RelativeImprovement = case PrevMetrics of undefined -> 0.0; _ when PrevMetrics#generation_metrics.best_fitness > 0 -> FitnessDelta / PrevMetrics#generation_metrics.best_fitness; _ -> 0.0 end, %% Compute fitness standard deviation FitnessStdDev = compute_fitness_std_dev(BestFitness, AvgFitness, WorstFitness), %% Compute structure metrics (simplified for now) StructureMetrics = compute_structure_metrics(GenStats), #generation_metrics{ generation = GenStats#generation_stats.generation, best_fitness = BestFitness, avg_fitness = AvgFitness, worst_fitness = WorstFitness, fitness_std_dev = FitnessStdDev, fitness_delta = FitnessDelta, relative_improvement = RelativeImprovement, population_diversity = FitnessStdDev, strategy_entropy = StructureMetrics#structure_metrics.strategy_entropy, evaluations_used = 1, %% Placeholder fitness_per_evaluation = BestFitness, diversity_corridors = StructureMetrics#structure_metrics.diversity_corridors, adaptation_readiness = StructureMetrics#structure_metrics.adaptation_readiness, params_used = State#meta_state.current_params, timestamp = erlang:timestamp() }; %% Handle map-based stats (from Elixir) stats_to_metrics(GenStatsMap, State) when is_map(GenStatsMap) -> PrevMetrics = case State#meta_state.metrics_history of [] -> undefined; [H | _] -> H end, BestFitness = maps:get(best_fitness, GenStatsMap, 0.0), AvgFitness = maps:get(avg_fitness, GenStatsMap, 0.0), WorstFitness = maps:get(worst_fitness, GenStatsMap, 0.0), Generation = maps:get(generation, GenStatsMap, 1), FitnessDelta = case PrevMetrics of undefined -> 0.0; _ -> BestFitness - PrevMetrics#generation_metrics.best_fitness end, RelativeImprovement = case PrevMetrics of undefined -> 0.0; _ when PrevMetrics#generation_metrics.best_fitness > 0 -> FitnessDelta / PrevMetrics#generation_metrics.best_fitness; _ -> 0.0 end, FitnessStdDev = compute_fitness_std_dev(BestFitness, AvgFitness, WorstFitness), #generation_metrics{ generation = Generation, best_fitness = BestFitness, avg_fitness = AvgFitness, worst_fitness = WorstFitness, fitness_std_dev = FitnessStdDev, fitness_delta = FitnessDelta, relative_improvement = RelativeImprovement, population_diversity = FitnessStdDev, strategy_entropy = 0.5, %% Default evaluations_used = 1, fitness_per_evaluation = BestFitness, diversity_corridors = 0.5, adaptation_readiness = 0.5, params_used = State#meta_state.current_params, timestamp = erlang:timestamp() }. %% @private Estimate fitness standard deviation from summary stats. compute_fitness_std_dev(Best, _Avg, Worst) -> %% Simple approximation assuming roughly normal distribution Range = Best - Worst, %% Standard deviation is roughly range/4 for normal distribution max(0.0, Range / 4.0). %% @private Compute structure metrics for normative awareness. compute_structure_metrics(_GenStats) -> %% Simplified implementation - can be enhanced with actual population analysis #structure_metrics{ diversity_corridors = 0.5, adaptation_readiness = 0.5, breakthrough_potential = 0.5, strategy_entropy = 0.5 }. %% @private Update metrics history (keep last N entries). update_history(Metrics, History, Config) -> WindowSize = Config#meta_config.history_window, NewHistory = [Metrics | History], lists:sublist(NewHistory, WindowSize). %% @private Compute input features for the LTC network. compute_input_features(Metrics, History, State) -> %% Normalize all features to roughly [-1, 1] or [0, 1] range %% Feature 1: Normalized best fitness BestFitness = Metrics#generation_metrics.best_fitness, MaxFitness = max(1.0, State#meta_state.best_fitness_ever), NormBestFitness = BestFitness / MaxFitness, %% Feature 2: Relative improvement (already normalized) RelImprovement = clamp(Metrics#generation_metrics.relative_improvement, -1.0, 1.0), %% Feature 3: Fitness variance (normalized by avg) AvgFitness = max(1.0, Metrics#generation_metrics.avg_fitness), NormVariance = clamp(Metrics#generation_metrics.fitness_std_dev / AvgFitness, 0.0, 1.0), %% Feature 4: Stagnation signal (increases with stagnation) StagnationSignal = sigmoid(State#meta_state.stagnation_count / 5.0), %% Feature 5: Generation progress (normalized) GenProgress = sigmoid(State#meta_state.generation / 100.0), %% Feature 6: Moving average improvement (trend) TrendSignal = compute_trend(History), %% Feature 7: Population diversity Diversity = clamp(Metrics#generation_metrics.population_diversity / MaxFitness, 0.0, 1.0), %% Feature 8: Strategy entropy Entropy = Metrics#generation_metrics.strategy_entropy, [NormBestFitness, RelImprovement, NormVariance, StagnationSignal, GenProgress, TrendSignal, Diversity, Entropy]. %% @private Compute improvement trend from history. compute_trend([]) -> 0.0; compute_trend([_]) -> 0.0; compute_trend(History) -> %% Simple linear regression on fitness improvements Improvements = [M#generation_metrics.relative_improvement || M <- History], case length(Improvements) of N when N < 2 -> 0.0; N -> %% Weighted average with more weight on recent Weights = [math:pow(0.9, I) || I <- lists:seq(0, N - 1)], WeightSum = lists:sum(Weights), WeightedSum = lists:sum([W * V || {W, V} <- lists:zip(Weights, Improvements)]), clamp(WeightedSum / WeightSum, -1.0, 1.0) end. %%% ============================================================================ %%% Internal Functions - LTC Forward Pass %%% ============================================================================ %% @private Forward pass through LTC network. forward_pass(Inputs, State) -> Config = State#meta_state.config, {_InputSize, HiddenLayers, OutputSize} = Config#meta_config.network_topology, NeuronType = Config#meta_config.neuron_type, LtcWeights = State#meta_state.ltc_weights, LtcStates = State#meta_state.ltc_states, %% Process through hidden layers {HiddenOutputs, NewStates1} = process_hidden_layers( Inputs, HiddenLayers, LtcWeights, LtcStates, NeuronType, 1 ), %% Process output layer NumHiddenLayers = length(HiddenLayers), OutputLayerIdx = NumHiddenLayers + 1, {Outputs, NewStates2} = process_output_layer( HiddenOutputs, OutputSize, LtcWeights, NewStates1, NeuronType, OutputLayerIdx ), {Outputs, NewStates2}. %% @private Process hidden layers. process_hidden_layers(Inputs, [], _Weights, States, _NeuronType, _LayerIdx) -> {Inputs, States}; process_hidden_layers(Inputs, [LayerSize | Rest], Weights, States, NeuronType, LayerIdx) -> %% Process each neuron in this layer {LayerOutputs, NewStates} = lists:foldl( fun(NeuronIdx, {OutAcc, StateAcc}) -> NeuronId = {LayerIdx, NeuronIdx}, Neuron = maps:get(NeuronId, Weights), OldState = maps:get(NeuronId, StateAcc, 0.0), %% Compute weighted input sum WeightedSum = compute_weighted_sum(Inputs, Neuron#meta_neuron.input_weights) + Neuron#meta_neuron.bias, %% Apply LTC dynamics {NewState, Output} = apply_ltc_dynamics( NeuronType, WeightedSum, OldState, Neuron#meta_neuron.time_constant, Neuron#meta_neuron.state_bound, Neuron#meta_neuron.backbone_weights, Neuron#meta_neuron.head_weights ), {OutAcc ++ [Output], StateAcc#{NeuronId => NewState}} end, {[], States}, lists:seq(1, LayerSize) ), process_hidden_layers(LayerOutputs, Rest, Weights, NewStates, NeuronType, LayerIdx + 1). %% @private Process output layer. process_output_layer(Inputs, OutputSize, Weights, States, NeuronType, LayerIdx) -> lists:foldl( fun(NeuronIdx, {OutAcc, StateAcc}) -> NeuronId = {LayerIdx, NeuronIdx}, Neuron = maps:get(NeuronId, Weights), OldState = maps:get(NeuronId, StateAcc, 0.0), WeightedSum = compute_weighted_sum(Inputs, Neuron#meta_neuron.input_weights) + Neuron#meta_neuron.bias, {NewState, Output} = apply_ltc_dynamics( NeuronType, WeightedSum, OldState, Neuron#meta_neuron.time_constant, Neuron#meta_neuron.state_bound, Neuron#meta_neuron.backbone_weights, Neuron#meta_neuron.head_weights ), {OutAcc ++ [Output], StateAcc#{NeuronId => NewState}} end, {[], States}, lists:seq(1, OutputSize) ). %% @private Compute weighted sum of inputs. compute_weighted_sum(Inputs, InputWeights) -> lists:foldl( fun({{_InputIdx, Weight}, InputVal}, Acc) -> Acc + Weight * InputVal; ({InputIdx, Weight}, Acc) when is_integer(InputIdx) -> InputVal = lists:nth(InputIdx, Inputs), Acc + Weight * InputVal end, 0.0, lists:zip(InputWeights, Inputs) ). %% @private Apply LTC dynamics (CfC or ODE). apply_ltc_dynamics(cfc, Input, State, Tau, Bound, BackboneWeights, HeadWeights) -> Params = #{ backbone_weights => BackboneWeights, head_weights => HeadWeights }, ltc_dynamics:evaluate_cfc(Input, State, Tau, Bound, Params); apply_ltc_dynamics(ltc, Input, State, Tau, Bound, _BackboneWeights, _HeadWeights) -> Dt = 0.1, %% Time step for ODE mode ltc_dynamics:evaluate_ode(Input, State, Tau, Bound, Dt). %%% ============================================================================ %%% Internal Functions - Output Processing %%% ============================================================================ %% @private Convert network outputs to parameter values. outputs_to_params(Outputs, Config) -> OutputMapping = get(output_mapping), ParamBounds = Config#meta_config.param_bounds, maps:fold( fun(Index, ParamName, Acc) -> RawOutput = lists:nth(Index, Outputs), %% Apply sigmoid to bound output to [0, 1] BoundedOutput = sigmoid(RawOutput), %% Scale to parameter range {Min, Max} = maps:get(ParamName, ParamBounds, {0.0, 1.0}), ParamValue = Min + BoundedOutput * (Max - Min), Acc#{ParamName => ParamValue} end, #{}, OutputMapping#output_mapping.index_to_param ). %% @private Apply momentum smoothing to parameter changes. apply_momentum(NewParams, State) -> Momentum = (State#meta_state.config)#meta_config.momentum, OldParams = State#meta_state.current_params, OldMomentum = State#meta_state.param_momentum, maps:fold( fun(Param, NewVal, Acc) -> OldVal = maps:get(Param, OldParams, NewVal), OldMom = maps:get(Param, OldMomentum, 0.0), %% Momentum update: v = momentum * v + (1 - momentum) * (new - old) Delta = NewVal - OldVal, NewMom = Momentum * OldMom + (1.0 - Momentum) * Delta, %% Apply momentum-smoothed change SmoothedVal = OldVal + NewMom, Acc#{Param => SmoothedVal} end, #{}, NewParams ). %% @private Update stagnation counter. update_stagnation(Metrics, State) -> case Metrics#generation_metrics.relative_improvement of Imp when Imp > 0.01 -> %% Significant improvement, reset counter 0; _ -> %% No improvement, increment counter State#meta_state.stagnation_count + 1 end. %% @private Log progress periodically. maybe_log_progress(State, _TrainingEvent) -> case State#meta_state.generation rem 10 of 0 -> Params = State#meta_state.current_params, error_logger:info_msg( "[meta_controller] Gen ~p: mutation_rate=~.3f, mutation_strength=~.3f, " "selection_ratio=~.3f, reward=~.2f, stagnation=~p~n", [ State#meta_state.generation, maps:get(mutation_rate, Params), maps:get(mutation_strength, Params), maps:get(selection_ratio, Params), State#meta_state.cumulative_reward, State#meta_state.stagnation_count ] ); _ -> ok end. %%% ============================================================================ %%% Utility Functions %%% ============================================================================ %% @private Sigmoid function. sigmoid(X) -> V = clamp(X, -10.0, 10.0), 1.0 / (1.0 + math:exp(-V)). %% @private Clamp value to range. clamp(Val, Min, _Max) when Val < Min -> Min; clamp(Val, _Min, Max) when Val > Max -> Max; clamp(Val, _Min, _Max) -> Val.