Current section
Files
Jump to
Current section
Files
src/topological_mutations.erl
%% @doc Topological mutation operators for neural network evolution.
%%
%% This module provides mutations that modify network structure:
%% - add_neuron: Insert neuron into existing connection
%% - add_outlink: Add output connection from neuron
%% - add_inlink: Add input connection to neuron
%% - add_sensorlink: Connect sensor to neuron
%% - add_actuatorlink: Connect neuron to actuator
%% - outsplice: Split output connection with new neuron
%% - add_bias: Add bias connection to neuron
%%
%% @author Macula.io
%% @copyright 2025 Macula.io, Apache-2.0
-module(topological_mutations).
-include("records.hrl").
-dialyzer({nowarn_function, [
add_bias/1,
add_outlink/1,
add_inlink/1,
add_neuron/1,
outsplice/1,
add_sensorlink/1,
add_actuatorlink/1,
add_sensor/1,
add_actuator/1
]}).
-export([
add_bias/1,
add_outlink/1,
add_inlink/1,
add_neuron/1,
outsplice/1,
add_sensorlink/1,
add_actuatorlink/1,
add_sensor/1,
add_actuator/1
]).
%%==============================================================================
%% Topological Mutations
%%==============================================================================
%% @doc Add bias input to a random neuron.
%%
%% Adds a bias connection (self-connection) to a neuron that
%% doesn't already have one.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_bias(term()) -> ok | {error, term()}.
add_bias(AgentId) ->
case mutation_helpers:select_random_neuron(AgentId) of
{error, no_neurons} ->
{error, no_neurons};
NeuronId ->
add_bias_to_neuron(NeuronId)
end.
add_bias_to_neuron(NeuronId) ->
Neuron = genotype:dirty_read({neuron, NeuronId}),
HasBias = lists:any(fun({InputId, _}) -> InputId == bias end, Neuron#neuron.input_idps),
do_add_bias(HasBias, Neuron).
do_add_bias(true, _Neuron) ->
{error, already_has_bias};
do_add_bias(false, Neuron) ->
BiasWeight = mutation_helpers:create_random_weight(),
NewInputIdps = [{bias, [BiasWeight]} | Neuron#neuron.input_idps],
UpdatedNeuron = Neuron#neuron{input_idps = NewInputIdps},
genotype:write(UpdatedNeuron),
ok.
%% @doc Add output link from a random neuron.
%%
%% Connects a neuron to another neuron or actuator that it's
%% not currently connected to.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_outlink(term()) -> ok | {error, term()}.
add_outlink(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
case mutation_helpers:select_random_neuron(AgentId) of
{error, no_neurons} ->
{error, no_neurons};
NeuronId ->
add_outlink_from_neuron(NeuronId, Cortex)
end.
add_outlink_from_neuron(NeuronId, Cortex) ->
Neuron = genotype:dirty_read({neuron, NeuronId}),
AllTargets = Cortex#cortex.neuron_ids ++ Cortex#cortex.actuator_ids,
CurrentOutputs = Neuron#neuron.output_ids,
AvailableTargets = AllTargets -- CurrentOutputs -- [NeuronId],
connect_to_target(NeuronId, Neuron, AvailableTargets).
connect_to_target(_NeuronId, _Neuron, []) ->
{error, no_available_targets};
connect_to_target(NeuronId, Neuron, Targets) ->
TargetId = selection_utils:random_select(Targets),
mutation_helpers:link_neuron_to_target(NeuronId, Neuron, TargetId),
ok.
%% @doc Add input link to a random neuron.
%%
%% Connects a sensor or another neuron to a neuron that it's
%% not currently connected to.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_inlink(term()) -> ok | {error, term()}.
add_inlink(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
case mutation_helpers:select_random_neuron(AgentId) of
{error, no_neurons} ->
{error, no_neurons};
NeuronId ->
add_inlink_to_neuron(NeuronId, Cortex)
end.
add_inlink_to_neuron(NeuronId, Cortex) ->
Neuron = genotype:dirty_read({neuron, NeuronId}),
AllSources = Cortex#cortex.sensor_ids ++ Cortex#cortex.neuron_ids,
CurrentInputIds = [InputId || {InputId, _} <- Neuron#neuron.input_idps],
AvailableSources = AllSources -- CurrentInputIds -- [NeuronId],
connect_from_source(NeuronId, Neuron, AvailableSources).
connect_from_source(_NeuronId, _Neuron, []) ->
{error, no_available_sources};
connect_from_source(NeuronId, Neuron, Sources) ->
SourceId = selection_utils:random_select(Sources),
mutation_helpers:link_source_to_neuron(SourceId, NeuronId, Neuron),
ok.
%% @doc Add a new neuron by splitting a connection.
%%
%% Selects a random connection, removes it, and inserts a new
%% neuron in the middle.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_neuron(term()) -> ok | {error, term()}.
add_neuron(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
case mutation_helpers:find_splittable_link(AgentId) of
{error, no_links} ->
{error, cannot_add_neuron};
{FromId, ToId, Weight} ->
insert_neuron(Agent, Cortex, FromId, ToId, Weight)
end.
insert_neuron(Agent, Cortex, FromId, ToId, Weight) ->
NewNeuronId = genotype:generate_id(neuron),
Constraint = Agent#agent.constraint,
AF = selection_utils:random_select(Constraint#constraint.neural_afs),
AggrF = selection_utils:random_select(Constraint#constraint.neural_aggr_fs),
%% Get innovation number for this node split (NEAT tracking)
%% Same FromId->ToId split always gets the same innovation
{NodeInnovation, _InLinkInn, _OutLinkInn} =
innovation:get_or_create_node_innovation(FromId, ToId),
NewNeuron = #neuron{
id = NewNeuronId,
generation = Agent#agent.generation,
cx_id = Agent#agent.cx_id,
af = AF,
aggr_f = AggrF,
input_idps = [{FromId, [Weight]}],
output_ids = [ToId],
ro_ids = [],
innovation = NodeInnovation
},
%% Update connections
mutation_helpers:update_source_output(FromId, ToId, NewNeuronId),
mutation_helpers:update_target_input(ToId, FromId, NewNeuronId, Weight),
%% Write new neuron and update cortex
genotype:write(NewNeuron),
NewNeuronIds = [NewNeuronId | Cortex#cortex.neuron_ids],
UpdatedCortex = Cortex#cortex{neuron_ids = NewNeuronIds},
genotype:write(UpdatedCortex),
ok.
%% @doc Add neuron by outsplicing (split output connection).
%%
%% Similar to add_neuron but specifically targets output connections.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec outsplice(term()) -> ok | {error, term()}.
outsplice(AgentId) ->
add_neuron(AgentId).
%% @doc Add link from a sensor to a neuron.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_sensorlink(term()) -> ok | {error, term()}.
add_sensorlink(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
select_sensor_and_link(Cortex).
select_sensor_and_link(#cortex{sensor_ids = []}) ->
{error, no_sensors};
select_sensor_and_link(Cortex) ->
SensorId = selection_utils:random_select(Cortex#cortex.sensor_ids),
Sensor = genotype:dirty_read({sensor, SensorId}),
AvailableNeurons = Cortex#cortex.neuron_ids -- Sensor#sensor.fanout_ids,
link_sensor_to_available_neuron(SensorId, Sensor, AvailableNeurons).
link_sensor_to_available_neuron(_SensorId, _Sensor, []) ->
{error, no_available_neurons};
link_sensor_to_available_neuron(SensorId, Sensor, Neurons) ->
NeuronId = selection_utils:random_select(Neurons),
mutation_helpers:link_sensor_to_neuron(SensorId, Sensor, NeuronId),
ok.
%% @doc Add link from a neuron to an actuator.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_actuatorlink(term()) -> ok | {error, term()}.
add_actuatorlink(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
select_actuator_and_link(Cortex).
select_actuator_and_link(#cortex{actuator_ids = []}) ->
{error, no_actuators};
select_actuator_and_link(Cortex) ->
ActuatorId = selection_utils:random_select(Cortex#cortex.actuator_ids),
Actuator = genotype:dirty_read({actuator, ActuatorId}),
AvailableNeurons = Cortex#cortex.neuron_ids -- Actuator#actuator.fanin_ids,
link_neuron_to_available_actuator(ActuatorId, Actuator, AvailableNeurons).
link_neuron_to_available_actuator(_ActuatorId, _Actuator, []) ->
{error, no_available_neurons};
link_neuron_to_available_actuator(ActuatorId, Actuator, Neurons) ->
NeuronId = selection_utils:random_select(Neurons),
mutation_helpers:link_neuron_to_actuator(NeuronId, ActuatorId, Actuator),
ok.
%% @doc Add a new sensor to the network.
%%
%% Selects a sensor type from the morphology that isn't already
%% in the network, creates it, and connects it to a random neuron.
%% Enables networks to evolve new perception capabilities.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_sensor(term()) -> ok | {error, term()}.
add_sensor(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
Constraint = Agent#agent.constraint,
Morphology = Constraint#constraint.morphology,
%% Get all available sensors from morphology
AllSensors = morphology:get_Sensors(Morphology),
%% Get sensor names already in network
CurrentSensorNames = get_current_sensor_names(Cortex#cortex.sensor_ids),
%% Find sensors not yet in network
CandidateSensors = [S || S <- AllSensors,
not lists:member(S#sensor.name, CurrentSensorNames)],
add_sensor_from_candidates(Agent, Cortex, CandidateSensors).
add_sensor_from_candidates(_Agent, _Cortex, []) ->
{error, no_available_sensors};
add_sensor_from_candidates(Agent, Cortex, Candidates) ->
%% Select random sensor template
SensorTemplate = selection_utils:random_select(Candidates),
%% Get innovation number for adding this sensor type
%% Same sensor type always gets the same innovation (NEAT tracking)
SensorInnovation = innovation:get_or_create_link_innovation(
{add_sensor, SensorTemplate#sensor.name}, Agent#agent.cx_id),
%% Create sensor with unique ID
NewSensorId = genotype:generate_id(sensor),
NewSensor = SensorTemplate#sensor{
id = NewSensorId,
cx_id = Agent#agent.cx_id,
generation = Agent#agent.generation,
fanout_ids = [],
innovation = SensorInnovation
},
%% Write sensor to Mnesia
genotype:write(NewSensor),
%% Update cortex with new sensor
NewSensorIds = [NewSensorId | Cortex#cortex.sensor_ids],
UpdatedCortex = Cortex#cortex{sensor_ids = NewSensorIds},
genotype:write(UpdatedCortex),
%% Connect to a random neuron
connect_sensor_to_random_neuron(NewSensorId, NewSensor, Cortex#cortex.neuron_ids).
get_current_sensor_names(SensorIds) ->
[begin
Sensor = genotype:dirty_read({sensor, SId}),
Sensor#sensor.name
end || SId <- SensorIds].
connect_sensor_to_random_neuron(_SensorId, _Sensor, []) ->
%% No neurons - sensor added but unconnected (will connect when neurons added)
ok;
connect_sensor_to_random_neuron(SensorId, Sensor, NeuronIds) ->
NeuronId = selection_utils:random_select(NeuronIds),
mutation_helpers:link_sensor_to_neuron(SensorId, Sensor, NeuronId),
ok.
%% @doc Add a new actuator to the network.
%%
%% Selects an actuator type from the morphology that isn't already
%% in the network, creates it, and connects a random neuron to it.
%% Enables networks to evolve new action capabilities.
%%
%% @param AgentId the agent to mutate
%% @returns ok or {error, term()}
-spec add_actuator(term()) -> ok | {error, term()}.
add_actuator(AgentId) ->
Agent = genotype:dirty_read({agent, AgentId}),
Cortex = genotype:dirty_read({cortex, Agent#agent.cx_id}),
Constraint = Agent#agent.constraint,
Morphology = Constraint#constraint.morphology,
%% Get all available actuators from morphology
AllActuators = morphology:get_Actuators(Morphology),
%% Get actuator names already in network
CurrentActuatorNames = get_current_actuator_names(Cortex#cortex.actuator_ids),
%% Find actuators not yet in network
CandidateActuators = [A || A <- AllActuators,
not lists:member(A#actuator.name, CurrentActuatorNames)],
add_actuator_from_candidates(Agent, Cortex, CandidateActuators).
add_actuator_from_candidates(_Agent, _Cortex, []) ->
{error, no_available_actuators};
add_actuator_from_candidates(Agent, Cortex, Candidates) ->
%% Select random actuator template
ActuatorTemplate = selection_utils:random_select(Candidates),
%% Get innovation number for adding this actuator type
%% Same actuator type always gets the same innovation (NEAT tracking)
ActuatorInnovation = innovation:get_or_create_link_innovation(
{add_actuator, ActuatorTemplate#actuator.name}, Agent#agent.cx_id),
%% Create actuator with unique ID
NewActuatorId = genotype:generate_id(actuator),
NewActuator = ActuatorTemplate#actuator{
id = NewActuatorId,
cx_id = Agent#agent.cx_id,
generation = Agent#agent.generation,
fanin_ids = [],
innovation = ActuatorInnovation
},
%% Write actuator to Mnesia
genotype:write(NewActuator),
%% Update cortex with new actuator
NewActuatorIds = [NewActuatorId | Cortex#cortex.actuator_ids],
UpdatedCortex = Cortex#cortex{actuator_ids = NewActuatorIds},
genotype:write(UpdatedCortex),
%% Connect a random neuron to this actuator
connect_random_neuron_to_actuator(NewActuatorId, NewActuator, Cortex#cortex.neuron_ids).
get_current_actuator_names(ActuatorIds) ->
[begin
Actuator = genotype:dirty_read({actuator, AId}),
Actuator#actuator.name
end || AId <- ActuatorIds].
connect_random_neuron_to_actuator(_ActuatorId, _Actuator, []) ->
%% No neurons - actuator added but unconnected (will connect when neurons added)
ok;
connect_random_neuron_to_actuator(ActuatorId, Actuator, NeuronIds) ->
NeuronId = selection_utils:random_select(NeuronIds),
mutation_helpers:link_neuron_to_actuator(NeuronId, ActuatorId, Actuator),
ok.