Current section

Files

Jump to
macula_neuroevolution src silos lc_chain.erl
Raw

src/silos/lc_chain.erl

%% @doc Chained LTC Controller for Liquid Conglomerate.
%%
%% This gen_server manages the L2→L1→L0 chain of LTC TWEANN networks.
%% Each level is a separate neural network that learns at different timescales.
%%
%% == Architecture ==
%%
%% L2 (Strategic, Ï„=100):
%% Inputs: Evolution metrics (fitness, stagnation, diversity)
%% Outputs: 4 strategic signals → L1 inputs
%%
%% L1 (Tactical, Ï„=50):
%% Inputs: L2's 4 outputs
%% Outputs: 5 tactical signals → L0 inputs
%%
%% L0 (Reactive, Ï„=10):
%% Inputs: L1's 5 outputs + emergent metrics (evolvable sensors)
%% Outputs: Final hyperparameters
%%
%% == Usage ==
%%
%% %% Start the chain
%% {ok, Pid} = lc_chain:start_link(Config),
%%
%% %% Forward pass: compute hyperparameters
%% EvoMetrics = #evolution_metrics{best_fitness = 0.85, ...},
%% EmergentMetrics = #emergent_metrics{convergence_rate = 0.02, ...},
%% Hyperparams = lc_chain:forward(Pid, EvoMetrics, EmergentMetrics),
%%
%% %% Train: provide reward signal
%% lc_chain:train(Pid, Reward).
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(lc_chain).
-behaviour(gen_server).
-include("lc_chain.hrl").
-include_lib("macula_tweann/include/records.hrl").
%% API
-export([
start_link/0,
start_link/1,
forward/3,
forward/2,
get_hyperparams/1,
train/2,
get_state/1,
reset/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
-define(SERVER, ?MODULE).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Start the LC chain with default configuration.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
start_link(#lc_chain_config{}).
%% @doc Start the LC chain with custom configuration.
-spec start_link(lc_chain_config()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Config, []).
%% @doc Forward pass through the chain.
%%
%% Computes hyperparameters by cascading through L2→L1→L0.
%%
%% @param ServerRef Server reference
%% @param EvoMetrics Evolution metrics (L2 inputs)
%% @param EmergentMetrics Emergent metrics (L0 additional inputs)
%% @returns Computed hyperparameters
-spec forward(pid() | atom(), evolution_metrics(), emergent_metrics()) -> lc_hyperparams().
forward(ServerRef, EvoMetrics, EmergentMetrics) ->
gen_server:call(ServerRef, {forward, EvoMetrics, EmergentMetrics}).
%% @doc Forward pass with only evolution metrics.
%%
%% Uses default emergent metrics.
-spec forward(pid() | atom(), evolution_metrics()) -> lc_hyperparams().
forward(ServerRef, EvoMetrics) ->
forward(ServerRef, EvoMetrics, #emergent_metrics{}).
%% @doc Get last computed hyperparameters.
-spec get_hyperparams(pid() | atom()) -> lc_hyperparams().
get_hyperparams(ServerRef) ->
gen_server:call(ServerRef, get_hyperparams).
%% @doc Train the chain with a reward signal.
%%
%% Backpropagates reward through all three levels.
%%
%% @param ServerRef Server reference
%% @param Reward Reward signal (higher = better performance)
-spec train(pid() | atom(), float()) -> ok.
train(ServerRef, Reward) ->
gen_server:cast(ServerRef, {train, Reward}).
%% @doc Get current chain state (for debugging/visualization).
-spec get_state(pid() | atom()) -> {ok, map()}.
get_state(ServerRef) ->
gen_server:call(ServerRef, get_state).
%% @doc Reset the chain state.
-spec reset(pid() | atom()) -> ok.
reset(ServerRef) ->
gen_server:cast(ServerRef, reset).
%%% ============================================================================
%%% gen_server Callbacks
%%% ============================================================================
init(Config) ->
error_logger:info_msg("[lc_chain] Initializing with tau: L2=~p, L1=~p, L0=~p~n",
[Config#lc_chain_config.l2_tau,
Config#lc_chain_config.l1_tau,
Config#lc_chain_config.l0_tau]),
%% Initialize genotype database (idempotent)
ok = genotype:init_db(),
%% Register LC morphologies
ok = lc_morphologies:register_all(),
%% Initialize each level
{ok, L2State} = init_level(l2, Config),
{ok, L1State} = init_level(l1, Config),
{ok, L0State} = init_level(l0, Config),
State = #lc_chain_state{
config = Config,
l2_state = L2State,
l1_state = L1State,
l0_state = L0State,
last_hyperparams = hyperparams_to_map(?LC_DEFAULT_HYPERPARAMS),
running = true
},
%% Subscribe to reward events (event-driven pattern)
controller_events:subscribe_to_reward(),
error_logger:info_msg("[lc_chain] Initialized all levels~n"),
{ok, State}.
handle_call({forward, EvoMetrics, EmergentMetrics}, _From, State) ->
{Hyperparams, NewState} = do_forward(EvoMetrics, EmergentMetrics, State),
{reply, Hyperparams, NewState};
handle_call(get_hyperparams, _From, State) ->
Hyperparams = map_to_hyperparams(State#lc_chain_state.last_hyperparams),
{reply, Hyperparams, State};
handle_call(get_state, _From, State) ->
Info = #{
config => State#lc_chain_state.config,
l2_outputs => (State#lc_chain_state.l2_state)#lc_level_state.last_outputs,
l1_outputs => (State#lc_chain_state.l1_state)#lc_level_state.last_outputs,
l0_outputs => (State#lc_chain_state.l0_state)#lc_level_state.last_outputs,
last_hyperparams => State#lc_chain_state.last_hyperparams,
cumulative_reward => State#lc_chain_state.cumulative_reward,
running => State#lc_chain_state.running
},
{reply, {ok, Info}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({train, Reward}, State) ->
%% DEPRECATED: Use controller_events:publish_reward/2 instead
logger:warning("[DEPRECATED] lc_chain:train/2 called. "
"Use controller_events:publish_reward/2 instead."),
NewState = do_train(Reward, State),
{noreply, NewState};
handle_cast(reset, State) ->
%% Reset neuron states but keep agent IDs
L2Reset = reset_level_state(State#lc_chain_state.l2_state),
L1Reset = reset_level_state(State#lc_chain_state.l1_state),
L0Reset = reset_level_state(State#lc_chain_state.l0_state),
NewState = State#lc_chain_state{
l2_state = L2Reset,
l1_state = L1Reset,
l0_state = L0Reset,
last_hyperparams = hyperparams_to_map(?LC_DEFAULT_HYPERPARAMS),
reward_history = [],
cumulative_reward = 0.0
},
{noreply, NewState};
handle_cast(_Msg, State) ->
{noreply, State}.
%% Handle reward signal event (event-driven pattern)
handle_info({neuro_event, <<"controller.reward">>, #{reward := Reward}}, State) ->
NewState = do_train(Reward, State),
{noreply, NewState};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%% ============================================================================
%%% Internal Functions - Initialization
%%% ============================================================================
%% @private Initialize a single LC level.
%%
%% Note: Hidden layer configuration (l2_hidden_layers, etc.) is reserved for
%% future enhancement. Currently, macula_tweann's construct_Agent creates a
%% minimal topology (sensors → actuators). Hidden neurons are added through
%% topology evolution via add_node mutations during training.
-spec init_level(l0 | l1 | l2, lc_chain_config()) -> {ok, lc_level_state()}.
init_level(Level, Config) ->
Morphology = level_to_morphology(Level),
Tau = level_to_tau(Level, Config),
%% Hidden layer sizes stored for potential future use with custom topology
_HiddenLayers = level_to_hidden(Level, Config),
%% Create constraint for agent construction
Constraint = #constraint{
morphology = Morphology,
neural_afs = [tanh],
neural_pfns = [none],
neural_aggr_fs = [dot_product]
},
%% Generate unique IDs for species and agent
SpecieId = {lc_chain, Level, genotype:generate_UniqueId()},
AgentId = {lc_agent, Level, genotype:generate_UniqueId()},
%% Construct the agent genotype using macula_tweann API
%% genotype:construct_Agent/3 expects (SpecieId, AgentId, Constraint)
try
_Result = genotype:construct_Agent(SpecieId, AgentId, Constraint),
error_logger:info_msg("[lc_chain] Created ~p agent: ~p~n", [Level, AgentId]),
%% Initialize neuron states to zero
NeuronStates = init_neuron_states(AgentId),
State = #lc_level_state{
level = Level,
agent_id = AgentId,
tau = Tau,
neuron_states = NeuronStates,
last_outputs = lists:duplicate(output_count(Level), 0.0),
connected_sensors = get_initial_sensors(Level),
generation = 0
},
{ok, State}
catch
Error:Reason:Stacktrace ->
error_logger:error_msg("[lc_chain] Failed to create ~p agent: ~p:~p~n~p~n",
[Level, Error, Reason, Stacktrace]),
{error, {Error, Reason}}
end.
%% @private Map level to morphology name.
level_to_morphology(l2) -> lc_l2;
level_to_morphology(l1) -> lc_l1;
level_to_morphology(l0) -> lc_l0.
%% @private Get tau for level from config.
level_to_tau(l2, Config) -> Config#lc_chain_config.l2_tau;
level_to_tau(l1, Config) -> Config#lc_chain_config.l1_tau;
level_to_tau(l0, Config) -> Config#lc_chain_config.l0_tau.
%% @private Get hidden layers for level from config.
level_to_hidden(l2, Config) -> Config#lc_chain_config.l2_hidden_layers;
level_to_hidden(l1, Config) -> Config#lc_chain_config.l1_hidden_layers;
level_to_hidden(l0, Config) -> Config#lc_chain_config.l0_hidden_layers.
%% @private Get output count for level.
output_count(l2) -> 4; % Strategic signals
output_count(l1) -> 5; % Tactical signals
output_count(l0) -> 5. % Hyperparameters
%% @private Get initially connected sensors for level.
get_initial_sensors(l2) ->
[l2_best_fitness, l2_avg_fitness, l2_fitness_improvement,
l2_fitness_variance, l2_stagnation_counter, l2_generation_progress,
l2_population_diversity, l2_species_count];
get_initial_sensors(l1) ->
[l1_from_l2_signal_1, l1_from_l2_signal_2,
l1_from_l2_signal_3, l1_from_l2_signal_4];
get_initial_sensors(l0) ->
%% Only fixed sensors initially - emergent can be added via topology evolution
[l0_from_l1_signal_1, l0_from_l1_signal_2, l0_from_l1_signal_3,
l0_from_l1_signal_4, l0_from_l1_signal_5].
%% @private Initialize neuron states to zero.
init_neuron_states(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
NeuronIds = Cortex#cortex.neuron_ids,
maps:from_list([{NId, 0.0} || NId <- NeuronIds]).
%% @private Reset level state (keep agent, reset neuron states).
reset_level_state(LevelState) ->
NeuronStates = init_neuron_states(LevelState#lc_level_state.agent_id),
OutputCount = output_count(LevelState#lc_level_state.level),
LevelState#lc_level_state{
neuron_states = NeuronStates,
last_outputs = lists:duplicate(OutputCount, 0.0),
generation = 0
}.
%%% ============================================================================
%%% Internal Functions - Forward Pass
%%% ============================================================================
%% @private Execute forward pass through the chain.
do_forward(EvoMetrics, EmergentMetrics, State) ->
Config = State#lc_chain_state.config,
%% L2: Evolution metrics → Strategic signals
L2Inputs = evolution_metrics_to_inputs(EvoMetrics),
{L2Outputs, L2NewState} = ltc_forward(
State#lc_chain_state.l2_state, L2Inputs, Config),
%% L1: L2 outputs → Tactical signals
L1Inputs = L2Outputs,
{L1Outputs, L1NewState} = ltc_forward(
State#lc_chain_state.l1_state, L1Inputs, Config),
%% L0: L1 outputs + emergent metrics → Hyperparameters
L0Inputs = L1Outputs ++ get_emergent_inputs(
State#lc_chain_state.l0_state, EmergentMetrics),
{L0Outputs, L0NewState} = ltc_forward(
State#lc_chain_state.l0_state, L0Inputs, Config),
%% Scale L0 outputs to hyperparameter ranges
Hyperparams = outputs_to_hyperparams(L0Outputs),
NewState = State#lc_chain_state{
l2_state = L2NewState,
l1_state = L1NewState,
l0_state = L0NewState,
last_hyperparams = hyperparams_to_map(Hyperparams)
},
{Hyperparams, NewState}.
%% @private LTC forward pass for a single level.
%%
%% Implements the LTC dynamics:
%% dx/dt = -x/Ï„ + f(x, I)
%% x(t+Δt) = x(t) + dx/dt * Δt
%%
%% For now, uses simple Euler integration with Δt=1 (one generation).
ltc_forward(LevelState, Inputs, _Config) ->
#lc_level_state{
agent_id = AgentId,
tau = Tau,
neuron_states = OldStates
} = LevelState,
%% Get network structure
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
%% Propagate through network with LTC dynamics
{NewStates, Outputs} = propagate_ltc(
Cortex, Inputs, OldStates, Tau),
NewLevelState = LevelState#lc_level_state{
neuron_states = NewStates,
last_outputs = Outputs,
generation = LevelState#lc_level_state.generation + 1
},
{Outputs, NewLevelState}.
%% @private Propagate inputs through LTC network.
%%
%% Full implementation:
%% 1. Map sensor inputs to sensor IDs
%% 2. For each neuron in topological order, compute weighted input and LTC update
%% 3. Collect outputs from actuators
propagate_ltc(Cortex, Inputs, OldStates, Tau) ->
SensorIds = Cortex#cortex.sensor_ids,
NeuronIds = Cortex#cortex.neuron_ids,
ActuatorIds = Cortex#cortex.actuator_ids,
%% Build sensor activation map from inputs
SensorActivations = build_sensor_activations(SensorIds, Inputs),
%% Process neurons in layer order (topological sort by layer coordinate)
SortedNeurons = sort_neurons_by_layer(NeuronIds),
%% For each neuron, compute LTC update
DeltaT = 1.0, % One generation = one time step
{NewStates, NeuronOutputs} = lists:foldl(
fun(NeuronId, {AccStates, AccOutputs}) ->
Neuron = genotype:dirty_read({neuron, NeuronId}),
OldState = maps:get(NeuronId, AccStates, 0.0),
%% Compute weighted input from all sources (sensors + other neurons)
NetInput = compute_weighted_input(Neuron, SensorActivations, AccOutputs),
%% LTC dynamics: dx/dt = -x/Ï„ + f(net_input)
%% Use neuron's time_constant if LTC, otherwise use level tau
NeuronTau = case Neuron#neuron.neuron_type of
ltc -> Neuron#neuron.time_constant;
cfc -> Neuron#neuron.time_constant;
_ -> Tau
end,
Decay = -OldState / NeuronTau,
Activation = activation(Neuron#neuron.af, NetInput),
NewState = OldState + (Decay + Activation) * DeltaT,
%% Clamp state to bounds
StateBound = Neuron#neuron.state_bound,
ClampedState = max(-StateBound, min(StateBound, NewState)),
%% Output is the tanh of state (standard) or state itself (LTC)
Output = case Neuron#neuron.neuron_type of
standard -> math:tanh(ClampedState);
_ -> ClampedState
end,
{maps:put(NeuronId, ClampedState, AccStates),
maps:put(NeuronId, Output, AccOutputs)}
end,
{OldStates, #{}},
SortedNeurons
),
%% Collect outputs from actuators
Outputs = [get_actuator_output(AId, NeuronOutputs) || AId <- ActuatorIds],
{NewStates, Outputs}.
%% @private Build sensor activation map from input vector.
build_sensor_activations(SensorIds, Inputs) ->
%% Pair each sensor ID with its corresponding input value
%% If more inputs than sensors, truncate; if fewer, pad with 0.0
PaddedInputs = Inputs ++ lists:duplicate(max(0, length(SensorIds) - length(Inputs)), 0.0),
Pairs = lists:zip(SensorIds, lists:sublist(PaddedInputs, length(SensorIds))),
maps:from_list(Pairs).
%% @private Sort neurons by layer coordinate for topological processing.
sort_neurons_by_layer(NeuronIds) ->
lists:sort(
fun(Id1, Id2) ->
{{Layer1, _}, neuron} = Id1,
{{Layer2, _}, neuron} = Id2,
Layer1 =< Layer2
end,
NeuronIds
).
%% @private Compute weighted input to a neuron from its input_idps.
compute_weighted_input(Neuron, SensorActivations, NeuronOutputs) ->
InputIdps = Neuron#neuron.input_idps,
WeightedSum = lists:foldl(
fun({SourceId, WeightSpecs}, Acc) ->
%% Get source activation (from sensor or neuron)
SourceValue = get_source_activation(SourceId, SensorActivations, NeuronOutputs),
%% Get first weight from weight specs (simplified: ignore delta, lr, params)
Weight = extract_weight(WeightSpecs),
Acc + SourceValue * Weight
end,
0.0,
InputIdps
),
%% Add bias (last element if present, typically weight from 'bias' source)
WeightedSum.
%% @private Get activation from a source (sensor or neuron).
get_source_activation(SourceId, SensorActivations, NeuronOutputs) ->
case SourceId of
bias -> 1.0; % Bias is always 1.0
_ ->
%% Try sensor first, then neuron
case maps:get(SourceId, SensorActivations, undefined) of
undefined -> maps:get(SourceId, NeuronOutputs, 0.0);
Value -> Value
end
end.
%% @private Extract weight value from weight specs.
%% Weight format: [{Weight, DeltaWeight, LR, Params}, ...] or just Weight
extract_weight([]) -> 0.0;
extract_weight([{W, _, _, _} | _]) -> W;
extract_weight([W | _]) when is_number(W) -> W;
extract_weight(W) when is_number(W) -> W;
extract_weight(_) -> 0.0.
%% @private Apply activation function.
activation(tanh, X) -> math:tanh(X);
activation(sigmoid, X) -> 1.0 / (1.0 + math:exp(-X));
activation(relu, X) -> max(0.0, X);
activation(linear, X) -> X;
activation(sin, X) -> math:sin(X);
activation(cos, X) -> math:cos(X);
activation(gaussian, X) -> math:exp(-(X * X));
activation(_, X) -> math:tanh(X). % Default to tanh
%% @private Get output from actuator by reading connected neurons.
get_actuator_output(ActuatorId, NeuronOutputs) ->
Actuator = genotype:dirty_read({actuator, ActuatorId}),
FaninIds = Actuator#actuator.fanin_ids,
case FaninIds of
[] ->
%% No inputs, return 0.0
0.0;
_ ->
%% Average of all connected neuron outputs
Values = [maps:get(NId, NeuronOutputs, 0.0) || NId <- FaninIds],
lists:sum(Values) / length(Values)
end.
%%% ============================================================================
%%% Internal Functions - Input/Output Conversion
%%% ============================================================================
%% @private Convert evolution metrics to normalized input vector.
evolution_metrics_to_inputs(#evolution_metrics{} = M) ->
[
normalize(M#evolution_metrics.best_fitness, 0.0, 1.0),
normalize(M#evolution_metrics.avg_fitness, 0.0, 1.0),
normalize(M#evolution_metrics.fitness_improvement, -0.1, 0.1),
normalize(M#evolution_metrics.fitness_variance, 0.0, 0.5),
normalize(M#evolution_metrics.stagnation_counter, 0, 50),
M#evolution_metrics.generation_progress, % Already 0-1
normalize(M#evolution_metrics.population_diversity, 0.0, 1.0),
normalize(M#evolution_metrics.species_count, 1, 20)
].
%% @private Get emergent metric inputs for connected sensors.
get_emergent_inputs(L0State, EmergentMetrics) ->
ConnectedSensors = L0State#lc_level_state.connected_sensors,
%% Only include emergent sensors (not l0_from_l1_* which are fixed)
EmergentSensors = [S || S <- ConnectedSensors,
is_emergent_sensor(S)],
[get_emergent_value(S, EmergentMetrics) || S <- EmergentSensors].
%% @private Check if sensor is an emergent sensor (not fixed L1 input).
is_emergent_sensor(l0_from_l1_signal_1) -> false;
is_emergent_sensor(l0_from_l1_signal_2) -> false;
is_emergent_sensor(l0_from_l1_signal_3) -> false;
is_emergent_sensor(l0_from_l1_signal_4) -> false;
is_emergent_sensor(l0_from_l1_signal_5) -> false;
is_emergent_sensor(_) -> true.
%% @private Get emergent metric value for a sensor.
get_emergent_value(l0_convergence_rate, M) ->
normalize(M#emergent_metrics.convergence_rate, -0.1, 0.1);
get_emergent_value(l0_fitness_plateau_duration, M) ->
normalize(M#emergent_metrics.fitness_plateau_duration, 0, 100);
get_emergent_value(l0_current_mutation_rate, M) ->
normalize(M#emergent_metrics.current_mutation_rate, 0.01, 0.5);
get_emergent_value(l0_current_selection_ratio, M) ->
normalize(M#emergent_metrics.current_selection_ratio, 0.1, 0.5);
get_emergent_value(l0_survival_rate, M) ->
M#emergent_metrics.survival_rate;
get_emergent_value(l0_offspring_rate, M) ->
M#emergent_metrics.offspring_rate;
get_emergent_value(l0_elite_age, M) ->
normalize(M#emergent_metrics.elite_age, 0, 50);
get_emergent_value(l0_complexity_trend, M) ->
normalize(M#emergent_metrics.complexity_trend, -1.0, 1.0);
get_emergent_value(l0_avg_network_size, M) ->
normalize(M#emergent_metrics.avg_network_size, 0, 100);
get_emergent_value(l0_species_extinction_rate, M) ->
normalize(M#emergent_metrics.species_extinction_rate, 0.0, 1.0);
get_emergent_value(l0_species_creation_rate, M) ->
normalize(M#emergent_metrics.species_creation_rate, 0.0, 1.0);
get_emergent_value(l0_innovation_rate, M) ->
normalize(M#emergent_metrics.innovation_rate, 0.0, 1.0);
get_emergent_value(l0_diversity_index, M) ->
M#emergent_metrics.diversity_index;
get_emergent_value(_, _) ->
0.5. % Unknown sensor, return neutral value
%% @private Convert L0 outputs to hyperparameters.
outputs_to_hyperparams(Outputs) ->
%% Scale outputs from [0,1] to parameter ranges
[MR, MS, SR, ANR, ACR | _] = Outputs ++ [0.5, 0.5, 0.5, 0.5, 0.5],
#lc_hyperparams{
mutation_rate = scale(MR, 0.01, 0.5),
mutation_strength = scale(MS, 0.05, 1.0),
selection_ratio = scale(SR, 0.1, 0.5),
add_node_rate = scale(ANR, 0.0, 0.1),
add_connection_rate = scale(ACR, 0.0, 0.2)
}.
%% @private Convert hyperparams record to map.
hyperparams_to_map(#lc_hyperparams{} = H) ->
#{
mutation_rate => H#lc_hyperparams.mutation_rate,
mutation_strength => H#lc_hyperparams.mutation_strength,
selection_ratio => H#lc_hyperparams.selection_ratio,
add_node_rate => H#lc_hyperparams.add_node_rate,
add_connection_rate => H#lc_hyperparams.add_connection_rate
}.
%% @private Convert map to hyperparams record.
map_to_hyperparams(Map) ->
#lc_hyperparams{
mutation_rate = maps:get(mutation_rate, Map, 0.1),
mutation_strength = maps:get(mutation_strength, Map, 0.3),
selection_ratio = maps:get(selection_ratio, Map, 0.2),
add_node_rate = maps:get(add_node_rate, Map, 0.03),
add_connection_rate = maps:get(add_connection_rate, Map, 0.05)
}.
%%% ============================================================================
%%% Internal Functions - Training
%%% ============================================================================
%% @private Train the LC chain using reward signal.
%%
%% Uses a simple policy gradient approach:
%% - Positive reward: reinforce recent weight perturbations
%% - Negative reward: reverse recent weight perturbations
%%
%% The learning rate is scaled by tau to provide temporal credit assignment:
%% - L0 (fast, tau=10): Learns quickly, adapts to immediate feedback
%% - L1 (medium, tau=50): Moderate learning, medium-term patterns
%% - L2 (slow, tau=100): Slow learning, long-term strategic changes
do_train(Reward, State) ->
Config = State#lc_chain_state.config,
LearningRate = Config#lc_chain_config.learning_rate,
%% Update weights for each level (reverse order: L0 first, then L1, L2)
%% This allows credit to flow from immediate to strategic
L0NewState = update_level_weights(State#lc_chain_state.l0_state, Reward, LearningRate),
L1NewState = update_level_weights(State#lc_chain_state.l1_state, Reward, LearningRate),
L2NewState = update_level_weights(State#lc_chain_state.l2_state, Reward, LearningRate),
%% Track reward history
NewCumulative = State#lc_chain_state.cumulative_reward + Reward,
Gen = L0NewState#lc_level_state.generation,
NewHistory = [{Gen, Reward} | State#lc_chain_state.reward_history],
State#lc_chain_state{
l0_state = L0NewState,
l1_state = L1NewState,
l2_state = L2NewState,
reward_history = lists:sublist(NewHistory, 100),
cumulative_reward = NewCumulative
}.
%% @private Update weights for a single level based on reward.
update_level_weights(LevelState, Reward, BaseLearningRate) ->
AgentId = LevelState#lc_level_state.agent_id,
Tau = LevelState#lc_level_state.tau,
%% Scale learning rate by tau (faster adaptation for lower tau)
%% LR_effective = LR_base * (10 / tau)
EffectiveLR = BaseLearningRate * (10.0 / Tau),
%% Get agent's cortex to find all neurons
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
NeuronIds = Cortex#cortex.neuron_ids,
%% Update each neuron's weights
lists:foreach(
fun(NeuronId) ->
update_neuron_weights(NeuronId, Reward, EffectiveLR)
end,
NeuronIds
),
LevelState.
%% @private Update weights for a single neuron using reward signal.
%%
%% Simple weight update rule:
%% w_new = w_old + lr * reward * sign(w_old) * |output|
%%
%% This reinforces the current weight direction when reward is positive
%% and reverses it when negative.
update_neuron_weights(NeuronId, Reward, LearningRate) ->
Neuron = genotype:dirty_read({neuron, NeuronId}),
InputIdps = Neuron#neuron.input_idps,
%% Update each input connection weight
NewInputIdps = lists:map(
fun({SourceId, WeightSpecs}) ->
NewWeightSpecs = update_weight_specs(WeightSpecs, Reward, LearningRate),
{SourceId, NewWeightSpecs}
end,
InputIdps
),
%% Write updated neuron back to genotype
UpdatedNeuron = Neuron#neuron{input_idps = NewInputIdps},
genotype:write(UpdatedNeuron),
ok.
%% @private Update weight specs with reward signal.
update_weight_specs([], _Reward, _LR) -> [];
update_weight_specs([{W, DW, WLR, Params} | Rest], Reward, LR) ->
%% Update weight using reward and a small noise term for exploration
Delta = LR * Reward * sign(W),
NewW = clamp_weight(W + Delta),
%% Update delta weight for momentum (optional)
NewDW = 0.9 * DW + 0.1 * Delta,
[{NewW, NewDW, WLR, Params} | update_weight_specs(Rest, Reward, LR)];
update_weight_specs([W | Rest], Reward, LR) when is_number(W) ->
%% Simple weight format (just a number)
Delta = LR * Reward * sign(W),
NewW = clamp_weight(W + Delta),
[NewW | update_weight_specs(Rest, Reward, LR)];
update_weight_specs([Other | Rest], Reward, LR) ->
[Other | update_weight_specs(Rest, Reward, LR)].
%% @private Sign function.
sign(X) when X > 0 -> 1.0;
sign(X) when X < 0 -> -1.0;
sign(_) -> 0.0.
%% @private Clamp weight to reasonable range.
clamp_weight(W) ->
max(-10.0, min(10.0, W)).
%%% ============================================================================
%%% Internal Functions - Utilities
%%% ============================================================================
%% @private Normalize value to [0, 1] range.
normalize(Value, Min, Max) when Max > Min ->
Clamped = max(Min, min(Max, Value)),
(Clamped - Min) / (Max - Min);
normalize(_, _, _) ->
0.5.
%% @private Scale value from [0, 1] to [Min, Max] range.
scale(Value, Min, Max) ->
Clamped = max(0.0, min(1.0, Value)),
Min + Clamped * (Max - Min).