Current section

Files

Jump to
macula_neuroevolution src liquid_conglomerate task_silo task_l0_actuators.erl
Raw

src/liquid_conglomerate/task_silo/task_l0_actuators.erl

%% @doc Task Silo L0 Actuators - Denormalizes and applies TWEANN outputs.
%%
%% Part of the Liquid Conglomerate v2 architecture. This module takes the
%% normalized output vector from the L0 TWEANN and converts it into actual
%% evolution parameters that control the neuroevolution process.
%%
%% == Responsibilities ==
%%
%% 1. Convert TWEANN outputs (0.0-1.0) to evolution parameter ranges
%% 2. Apply outputs to neuroevolution_server (evolution params)
%% 3. Apply outputs to opponent_archive (self-play archive params)
%% 4. Handle delta outputs (population_size_delta, compatibility_threshold_delta, archive_max_size_delta)
%% 5. Track applied values for debugging/monitoring
%%
%% == Actuators (16 total) ==
%%
%% Evolution actuators (1-12):
%% - mutation_rate, mutation_strength, selection_ratio
%% - add_node_rate, add_connection_rate, delete_connection_rate
%% - weight_perturb_vs_replace, crossover_rate, interspecies_crossover_rate
%% - elitism_count, population_size_delta, compatibility_threshold_delta
%%
%% Self-play archive actuators (13-16):
%% - archive_threshold_percentile: Entry threshold for adding to archive
%% - archive_sampling_temperature: Opponent selection strategy (0=uniform, 1=fitness-weighted)
%% - archive_prune_ratio: Keep top X% when pruning (0.5-1.0)
%% - archive_max_size_delta: Adjust archive max size (-5 to +5)
%%
%% == Usage ==
%%
%% %% Start the actuator controller
%% {ok, Pid} = task_l0_actuators:start_link(Config),
%%
%% %% Apply TWEANN output vector
%% task_l0_actuators:apply_output_vector(OutputVector),
%%
%% %% Get current actuator values
%% Values = task_l0_actuators:get_actuator_values(),
%% %% Returns: #{mutation_rate => 0.15, crossover_rate => 0.7, ...}
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(task_l0_actuators).
-behaviour(gen_server).
%% API
-export([
start_link/0,
start_link/1,
apply_outputs/1,
apply_outputs/2,
apply_output_vector/1,
apply_output_vector/2,
get_actuator_values/0,
get_actuator_values/1,
get_raw_outputs/0,
get_raw_outputs/1,
get_evolution_params/0,
get_evolution_params/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
-define(SERVER, ?MODULE).
-record(state, {
%% Configuration
hyperparameters :: map(), % L0 hyperparameters (from L1)
%% Current values
raw_outputs :: [float()], % Raw TWEANN outputs (0.0-1.0)
actuator_values :: map(), % Denormalized actuator values
%% Current evolution state (for delta calculations)
current_population_size :: pos_integer(),
current_compatibility_threshold :: float(),
%% Self-play archive state (for delta calculations)
current_archive_max_size :: pos_integer(),
%% Target callback (for integration with neuroevolution_server)
evolution_callback :: fun((map()) -> ok) | undefined,
archive_callback :: fun((map()) -> ok) | undefined
}).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Start the actuator controller with default configuration.
-spec start_link() -> {ok, pid()} | ignore | {error, term()}.
start_link() ->
start_link(#{}).
%% @doc Start the actuator controller with custom configuration.
-spec start_link(map()) -> {ok, pid()} | ignore | {error, term()}.
start_link(Config) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Config, []).
%% @doc Apply TWEANN outputs as a map.
-spec apply_outputs(map()) -> ok.
apply_outputs(OutputMap) ->
gen_server:cast(?SERVER, {apply_outputs, OutputMap}).
%% @doc Apply TWEANN outputs as a map (specific server).
-spec apply_outputs(pid(), map()) -> ok.
apply_outputs(Pid, OutputMap) ->
gen_server:cast(Pid, {apply_outputs, OutputMap}).
%% @doc Apply TWEANN output vector (ordered list).
-spec apply_output_vector([float()]) -> ok.
apply_output_vector(OutputVector) ->
gen_server:cast(?SERVER, {apply_output_vector, OutputVector}).
%% @doc Apply TWEANN output vector (specific server).
-spec apply_output_vector(pid(), [float()]) -> ok.
apply_output_vector(Pid, OutputVector) ->
gen_server:cast(Pid, {apply_output_vector, OutputVector}).
%% @doc Get current denormalized actuator values.
-spec get_actuator_values() -> map().
get_actuator_values() ->
gen_server:call(?SERVER, get_actuator_values).
%% @doc Get current denormalized actuator values (specific server).
-spec get_actuator_values(pid()) -> map().
get_actuator_values(Pid) ->
gen_server:call(Pid, get_actuator_values).
%% @doc Get raw TWEANN outputs (before denormalization).
-spec get_raw_outputs() -> [float()].
get_raw_outputs() ->
gen_server:call(?SERVER, get_raw_outputs).
%% @doc Get raw TWEANN outputs (specific server).
-spec get_raw_outputs(pid()) -> [float()].
get_raw_outputs(Pid) ->
gen_server:call(Pid, get_raw_outputs).
%% @doc Get evolution parameters ready for neuroevolution_server.
-spec get_evolution_params() -> map().
get_evolution_params() ->
gen_server:call(?SERVER, get_evolution_params).
%% @doc Get evolution parameters (specific server).
-spec get_evolution_params(pid()) -> map().
get_evolution_params(Pid) ->
gen_server:call(Pid, get_evolution_params).
%%% ============================================================================
%%% gen_server Callbacks
%%% ============================================================================
init(Config) ->
Hyperparams = maps:get(hyperparameters, Config,
task_l0_morphology:get_l0_defaults()),
EvolutionCallback = maps:get(evolution_callback, Config, undefined),
ArchiveCallback = maps:get(archive_callback, Config, undefined),
InitialPopSize = maps:get(initial_population_size, Config, 100),
InitialCompatThreshold = maps:get(initial_compatibility_threshold, Config, 3.0),
InitialArchiveMaxSize = maps:get(initial_archive_max_size, Config, 50),
State = #state{
hyperparameters = Hyperparams,
raw_outputs = lists:duplicate(task_l0_morphology:actuator_count(), 0.5),
actuator_values = initial_actuator_values(),
current_population_size = InitialPopSize,
current_compatibility_threshold = InitialCompatThreshold,
current_archive_max_size = InitialArchiveMaxSize,
evolution_callback = EvolutionCallback,
archive_callback = ArchiveCallback
},
{ok, State}.
handle_call(get_actuator_values, _From, State) ->
{reply, State#state.actuator_values, State};
handle_call(get_raw_outputs, _From, State) ->
{reply, State#state.raw_outputs, State};
handle_call(get_evolution_params, _From, State) ->
Params = build_evolution_params(State),
{reply, Params, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({apply_output_vector, OutputVector}, State) ->
NewState = process_output_vector(OutputVector, State),
{noreply, NewState};
handle_cast({apply_outputs, OutputMap}, State) ->
%% Convert map to ordered vector
ActuatorNames = task_l0_morphology:actuator_names(),
OutputVector = [maps:get(Name, OutputMap, 0.5) || Name <- ActuatorNames],
NewState = process_output_vector(OutputVector, State),
{noreply, NewState};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
%%% ============================================================================
%%% Internal Functions - Output Processing
%%% ============================================================================
%% @private Process output vector and apply to targets.
process_output_vector(OutputVector, State) ->
ActuatorNames = task_l0_morphology:actuator_names(),
Hyperparams = State#state.hyperparameters,
%% Denormalize each output
ActuatorValues = denormalize_outputs(ActuatorNames, OutputVector, Hyperparams, State),
%% Apply delta values to current state
PopDelta = maps:get(population_size_delta, ActuatorValues, 0),
CompatDelta = maps:get(compatibility_threshold_delta, ActuatorValues, 0.0),
ArchiveDelta = maps:get(archive_max_size_delta, ActuatorValues, 0),
NewPopSize = max(10, State#state.current_population_size + PopDelta),
NewCompatThreshold = max(0.5, State#state.current_compatibility_threshold + CompatDelta),
NewArchiveMaxSize = max(10, State#state.current_archive_max_size + ArchiveDelta),
%% Update actuator values with computed absolute values
FinalActuatorValues = ActuatorValues#{
current_population_size => NewPopSize,
current_compatibility_threshold => NewCompatThreshold,
current_archive_max_size => NewArchiveMaxSize
},
%% Apply to targets
NewState = State#state{
raw_outputs = OutputVector,
actuator_values = FinalActuatorValues,
current_population_size = NewPopSize,
current_compatibility_threshold = NewCompatThreshold,
current_archive_max_size = NewArchiveMaxSize
},
apply_to_targets(FinalActuatorValues, NewState),
NewState.
%% @private Denormalize outputs based on actuator specs.
denormalize_outputs(Names, Values, Hyperparams, State) ->
lists:foldl(
fun({Name, RawValue}, Acc) ->
DenormValue = denormalize_actuator(Name, RawValue, Hyperparams, State),
maps:put(Name, DenormValue, Acc)
end,
#{},
lists:zip(Names, Values)
).
%% @private Denormalize a single actuator value.
denormalize_actuator(mutation_rate, RawValue, Hyperparams, _State) ->
%% Range determined by L0 hyperparameters
MinRate = maps:get(mutation_rate_min, Hyperparams, 0.01),
MaxRate = maps:get(mutation_rate_max, Hyperparams, 0.50),
lerp(RawValue, MinRate, MaxRate);
denormalize_actuator(mutation_strength, RawValue, _Hyperparams, _State) ->
%% Range: 0.05 to 1.0
lerp(RawValue, 0.05, 1.0);
denormalize_actuator(selection_ratio, RawValue, _Hyperparams, _State) ->
%% Range: 0.05 to 0.50
lerp(RawValue, 0.05, 0.50);
denormalize_actuator(add_node_rate, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 0.15
lerp(RawValue, 0.0, 0.15);
denormalize_actuator(add_connection_rate, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 0.25
lerp(RawValue, 0.0, 0.25);
denormalize_actuator(delete_connection_rate, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 0.10
lerp(RawValue, 0.0, 0.10);
denormalize_actuator(weight_perturb_vs_replace, RawValue, _Hyperparams, _State) ->
%% Range: 0.5 to 1.0 (higher = more perturbation, less replacement)
lerp(RawValue, 0.5, 1.0);
denormalize_actuator(crossover_rate, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 0.9
lerp(RawValue, 0.0, 0.9);
denormalize_actuator(interspecies_crossover_rate, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 0.3
lerp(RawValue, 0.0, 0.3);
denormalize_actuator(elitism_count, RawValue, _Hyperparams, _State) ->
%% Range: 1 to 10
round(lerp(RawValue, 1.0, 10.0));
denormalize_actuator(population_size_delta, RawValue, _Hyperparams, _State) ->
%% Range: -10 to +10 (centered at 0.5)
%% 0.0 -> -10, 0.5 -> 0, 1.0 -> +10
round(lerp(RawValue, -10.0, 10.0));
denormalize_actuator(compatibility_threshold_delta, RawValue, _Hyperparams, _State) ->
%% Range: -0.5 to +0.5 (centered at 0.5)
lerp(RawValue, -0.5, 0.5);
%% Self-play archive actuators (13-16)
denormalize_actuator(archive_threshold_percentile, RawValue, Hyperparams, _State) ->
%% Range determined by L0 hyperparameters
MinThreshold = maps:get(archive_threshold_min, Hyperparams, 0.3),
MaxThreshold = maps:get(archive_threshold_max, Hyperparams, 0.95),
lerp(RawValue, MinThreshold, MaxThreshold);
denormalize_actuator(archive_sampling_temperature, RawValue, _Hyperparams, _State) ->
%% Range: 0.0 to 1.0 (0=uniform sampling, 1=fitness-weighted)
lerp(RawValue, 0.0, 1.0);
denormalize_actuator(archive_prune_ratio, RawValue, _Hyperparams, _State) ->
%% Range: 0.5 to 1.0 (keep top X% when pruning)
lerp(RawValue, 0.5, 1.0);
denormalize_actuator(archive_max_size_delta, RawValue, _Hyperparams, _State) ->
%% Range: -5 to +5 (centered at 0.5)
%% 0.0 -> -5, 0.5 -> 0, 1.0 -> +5
round(lerp(RawValue, -5.0, 5.0));
denormalize_actuator(_Unknown, RawValue, _Hyperparams, _State) ->
RawValue.
%% @private Apply actuator values to their targets.
apply_to_targets(ActuatorValues, State) ->
%% Build and apply evolution parameters
EvolutionParams = build_evolution_params_from_values(ActuatorValues, State),
apply_evolution_params(EvolutionParams, State),
%% Build and apply archive parameters
ArchiveParams = build_archive_params_from_values(ActuatorValues, State),
apply_archive_params(ArchiveParams, State),
ok.
%% @private Apply evolution parameters via callback or directly.
apply_evolution_params(EvolutionParams, State) ->
case State#state.evolution_callback of
undefined ->
%% Try to update neuroevolution_server directly
case whereis(neuroevolution_server) of
undefined -> ok;
_Pid ->
%% Would call neuroevolution_server:update_params(EvolutionParams)
ok
end;
Callback ->
Callback(EvolutionParams)
end.
%% @private Apply archive parameters via callback or directly.
apply_archive_params(ArchiveParams, State) ->
case State#state.archive_callback of
undefined ->
%% Try to update opponent_archive directly
case whereis(opponent_archive) of
undefined -> ok;
_Pid ->
%% Would call opponent_archive:update_params(ArchiveParams)
ok
end;
Callback ->
Callback(ArchiveParams)
end.
%% @private Build evolution parameters from actuator values.
build_evolution_params_from_values(ActuatorValues, State) ->
#{
%% Mutation parameters
mutation_rate => maps:get(mutation_rate, ActuatorValues, 0.1),
mutation_strength => maps:get(mutation_strength, ActuatorValues, 0.3),
add_node_rate => maps:get(add_node_rate, ActuatorValues, 0.03),
add_connection_rate => maps:get(add_connection_rate, ActuatorValues, 0.05),
delete_connection_rate => maps:get(delete_connection_rate, ActuatorValues, 0.02),
weight_perturb_probability => maps:get(weight_perturb_vs_replace, ActuatorValues, 0.9),
%% Selection parameters
selection_ratio => maps:get(selection_ratio, ActuatorValues, 0.2),
elitism_count => maps:get(elitism_count, ActuatorValues, 2),
%% Crossover parameters
crossover_rate => maps:get(crossover_rate, ActuatorValues, 0.75),
interspecies_crossover_rate => maps:get(interspecies_crossover_rate, ActuatorValues, 0.05),
%% Population parameters
population_size => State#state.current_population_size,
compatibility_threshold => State#state.current_compatibility_threshold
}.
%% @private Build evolution params for get_evolution_params call.
build_evolution_params(State) ->
build_evolution_params_from_values(State#state.actuator_values, State).
%% @private Build archive parameters from actuator values.
build_archive_params_from_values(ActuatorValues, State) ->
#{
%% Archive entry threshold
threshold_percentile => maps:get(archive_threshold_percentile, ActuatorValues, 0.7),
%% Opponent sampling strategy
sampling_temperature => maps:get(archive_sampling_temperature, ActuatorValues, 0.5),
%% Archive pruning
prune_ratio => maps:get(archive_prune_ratio, ActuatorValues, 0.8),
%% Archive size
max_size => State#state.current_archive_max_size
}.
%%% ============================================================================
%%% Internal Functions - Utilities
%%% ============================================================================
%% @private Initial actuator values.
initial_actuator_values() ->
#{
%% Evolution actuators (1-12)
mutation_rate => 0.10,
mutation_strength => 0.30,
selection_ratio => 0.20,
add_node_rate => 0.03,
add_connection_rate => 0.05,
delete_connection_rate => 0.02,
weight_perturb_vs_replace => 0.90,
crossover_rate => 0.75,
interspecies_crossover_rate => 0.05,
elitism_count => 2,
population_size_delta => 0,
compatibility_threshold_delta => 0.0,
%% Self-play archive actuators (13-16)
archive_threshold_percentile => 0.70,
archive_sampling_temperature => 0.50,
archive_prune_ratio => 0.80,
archive_max_size_delta => 0
}.
%% @private Linear interpolation from 0.0-1.0 to target range.
lerp(T, Min, Max) ->
ClampedT = clamp(T, 0.0, 1.0),
Min + ClampedT * (Max - Min).
%% @private Clamp value to range.
clamp(Value, Min, Max) ->
max(Min, min(Max, Value)).