%% @doc Synchronous neural network evaluator for inference. %% %% This module provides synchronous (blocking) forward propagation %% for neural networks. Unlike the process-based cortex/neuron approach %% used during training, this is designed for fast inference in %% real-time applications like games. %% %% == Usage == %% %% Create a network from a genotype: %% {ok, Network} = network_evaluator:from_genotype(AgentId) %% %% Or create a simple feedforward network: %% Network = network_evaluator:create_feedforward(42, [16, 8], 6) %% %% Evaluate: %% Outputs = network_evaluator:evaluate(Network, Inputs) %% %% @copyright 2025 Macula.io -module(network_evaluator). -export([ create_feedforward/3, create_feedforward/4, evaluate/2, evaluate_with_activations/2, from_genotype/1, get_weights/1, set_weights/2, get_topology/1, get_viz_data/3, %% Serialization to_json/1, from_json/1, to_binary/1, from_binary/1 ]). -record(network, { layers :: [layer()], activation :: atom(), %% Optional compiled NIF reference for fast evaluation compiled_ref :: reference() | undefined }). -type layer() :: {Weights :: [[float()]], Biases :: [float()]}. -type network() :: #network{}. -export_type([network/0]). %% @doc Create a feedforward network with random weights. %% %% @param InputSize Number of inputs %% @param HiddenSizes List of hidden layer sizes %% @param OutputSize Number of outputs %% @returns Network record -spec create_feedforward(pos_integer(), [pos_integer()], pos_integer()) -> network(). create_feedforward(InputSize, HiddenSizes, OutputSize) -> create_feedforward(InputSize, HiddenSizes, OutputSize, tanh). %% @doc Create a feedforward network with specified activation. -spec create_feedforward(pos_integer(), [pos_integer()], pos_integer(), atom()) -> network(). create_feedforward(InputSize, HiddenSizes, OutputSize, Activation) -> LayerSizes = [InputSize | HiddenSizes] ++ [OutputSize], Layers = create_layers(LayerSizes), Network = #network{layers = Layers, activation = Activation, compiled_ref = undefined}, %% Attempt to compile for NIF acceleration maybe_compile_for_nif(Network). %% @doc Evaluate the network with given inputs. %% %% Performs synchronous forward propagation through all layers. %% Uses NIF acceleration if available and network was compiled. %% %% @param Network The network record %% @param Inputs List of input values (must match input size) %% @returns List of output values -spec evaluate(network(), [float()]) -> [float()]. evaluate(#network{compiled_ref = CompiledRef}, Inputs) when CompiledRef =/= undefined -> %% Fast path: use NIF-compiled network tweann_nif:evaluate(CompiledRef, Inputs); evaluate(#network{layers = Layers, activation = Activation}, Inputs) -> %% Fallback: pure Erlang evaluation forward_propagate(Layers, Inputs, Activation). %% @doc Load a network from a genotype stored in Mnesia. %% %% Reads the agent's neural network structure and weights from Mnesia %% and creates an evaluator network. %% %% @param AgentId The agent identifier %% @returns {ok, Network} | {error, Reason} -spec from_genotype(term()) -> {ok, network()} | {error, term()}. from_genotype(AgentId) -> case load_genotype_structure(AgentId) of {ok, Structure} -> Network = build_network_from_structure(Structure), {ok, Network}; {error, Reason} -> {error, Reason} end. %% @doc Get all weights from the network as a flat list. %% %% Useful for evolution - can be mutated and set back. -spec get_weights(network()) -> [float()]. get_weights(#network{layers = Layers}) -> lists:flatmap( fun({Weights, Biases}) -> lists:flatten(Weights) ++ Biases end, Layers ). %% @doc Set weights from a flat list. %% %% The list must have the same number of elements as returned by get_weights/1. %% Re-compiles the network for NIF acceleration if available. -spec set_weights(network(), [float()]) -> network(). set_weights(Network = #network{layers = Layers}, FlatWeights) -> {NewLayers, []} = lists:mapfoldl( fun({Weights, Biases}, Remaining) -> WeightCount = length(Weights) * length(hd(Weights)), BiasCount = length(Biases), {WeightVals, Rest1} = lists:split(WeightCount, Remaining), {BiasVals, Rest2} = lists:split(BiasCount, Rest1), NewWeights = reshape_weights(WeightVals, length(hd(Weights))), {{NewWeights, BiasVals}, Rest2} end, FlatWeights, Layers ), %% Create updated network and recompile for NIF UpdatedNetwork = Network#network{layers = NewLayers, compiled_ref = undefined}, maybe_compile_for_nif(UpdatedNetwork). %%============================================================================== %% Internal Functions %%============================================================================== %% @private Create layer weight matrices create_layers(LayerSizes) -> Pairs = lists:zip( lists:droplast(LayerSizes), tl(LayerSizes) ), [create_layer(FromSize, ToSize) || {FromSize, ToSize} <- Pairs]. %% @private Create a single layer with random weights create_layer(FromSize, ToSize) -> %% Xavier initialization: scale by sqrt(2 / (fan_in + fan_out)) Scale = math:sqrt(2.0 / (FromSize + ToSize)), Weights = [ [(rand:uniform() * 2 - 1) * Scale || _ <- lists:seq(1, FromSize)] || _ <- lists:seq(1, ToSize) ], Biases = [(rand:uniform() * 0.2 - 0.1) || _ <- lists:seq(1, ToSize)], {Weights, Biases}. %% @private Forward propagate through all layers forward_propagate([], Activations, _Activation) -> Activations; forward_propagate([{Weights, Biases} | RestLayers], Inputs, Activation) -> %% For each neuron: weighted sum of inputs + bias, then activation Outputs = lists:zipwith( fun(NeuronWeights, Bias) -> Sum = dot_product(NeuronWeights, Inputs) + Bias, apply_activation(Sum, Activation) end, Weights, Biases ), forward_propagate(RestLayers, Outputs, Activation). %% @private Dot product of two vectors dot_product(Weights, Inputs) -> lists:sum(lists:zipwith(fun(W, I) -> W * I end, Weights, Inputs)). %% @private Apply activation function apply_activation(X, tanh) -> math:tanh(X); apply_activation(X, sigmoid) -> 1.0 / (1.0 + math:exp(-X)); apply_activation(X, relu) -> max(0.0, X); apply_activation(X, linear) -> X; apply_activation(X, _) -> math:tanh(X). %% @private Reshape flat weights into matrix reshape_weights(FlatWeights, RowSize) -> reshape_weights(FlatWeights, RowSize, []). reshape_weights([], _RowSize, Acc) -> lists:reverse(Acc); reshape_weights(Weights, RowSize, Acc) -> {Row, Rest} = lists:split(RowSize, Weights), reshape_weights(Rest, RowSize, [Row | Acc]). %%============================================================================== %% NIF Compilation %%============================================================================== %% @private Attempt to compile network for NIF acceleration. %% %% If the NIF is loaded, compiles the network to a flat representation %% that can be evaluated much faster. Falls back to Erlang evaluation %% if NIF is not available. -spec maybe_compile_for_nif(network()) -> network(). maybe_compile_for_nif(Network = #network{layers = Layers, activation = Activation}) -> case tweann_nif:is_loaded() of true -> try {Nodes, InputCount, OutputIndices} = build_nif_network(Layers, Activation), CompiledRef = tweann_nif:compile_network(Nodes, InputCount, OutputIndices), Network#network{compiled_ref = CompiledRef} catch _:_ -> %% Compilation failed, use Erlang fallback Network end; false -> Network end. %% @private Build the flat node representation for NIF compilation. %% %% Converts the layer-based structure to a flat list of nodes in %% topological order (inputs first, then hidden layers, then outputs). %% %% Node format: {Index, Type, Activation, Bias, [{FromIndex, Weight}, ...]} -spec build_nif_network([layer()], atom()) -> {Nodes :: list(), InputCount :: non_neg_integer(), OutputIndices :: [non_neg_integer()]}. build_nif_network(Layers, Activation) -> LayerSizes = extract_layer_sizes(Layers), InputCount = hd(LayerSizes), %% Build input nodes (no connections, linear activation) InputNodes = [{I, input, linear, 0.0, []} || I <- lists:seq(0, InputCount - 1)], %% Build hidden and output nodes layer by layer {HiddenOutputNodes, _} = lists:foldl( fun({WeightMatrix, Biases}, {Acc, PrevLayerStart}) -> PrevLayerSize = length(hd(WeightMatrix)), CurrentLayerStart = PrevLayerStart + PrevLayerSize, %% Each row in WeightMatrix is weights for one neuron %% WeightMatrix[neuron][input] = weight from input to neuron NewNodes = lists:zipwith( fun(NeuronWeights, Bias) -> NeuronIdx = CurrentLayerStart + length(Acc) - length(InputNodes), Connections = [{PrevLayerStart + I, W} || {I, W} <- lists:zip( lists:seq(0, length(NeuronWeights) - 1), NeuronWeights)], {NeuronIdx, hidden, Activation, Bias, Connections} end, WeightMatrix, Biases ), {Acc ++ NewNodes, CurrentLayerStart} end, {[], 0}, Layers ), %% Combine all nodes AllNodes = InputNodes ++ HiddenOutputNodes, %% Re-index all nodes sequentially from 0 {FinalNodes, _} = lists:mapfoldl( fun({_OldIdx, Type, Act, Bias, Conns}, NewIdx) -> {{NewIdx, Type, Act, Bias, Conns}, NewIdx + 1} end, 0, AllNodes ), %% Calculate output indices (last layer nodes) TotalNodes = length(FinalNodes), OutputCount = length(element(2, lists:last(Layers))), OutputIndices = lists:seq(TotalNodes - OutputCount, TotalNodes - 1), {FinalNodes, InputCount, OutputIndices}. %% @private Load genotype structure from ETS load_genotype_structure(AgentId) -> case genotype:dirty_read({agent, AgentId}) of undefined -> {error, agent_not_found}; Agent -> CxId = element(3, Agent), %% #agent.cx_id case genotype:dirty_read({cortex, CxId}) of undefined -> {error, cortex_not_found}; Cortex -> %% Load neurons NeuronIds = element(5, Cortex), %% #cortex.neuron_ids Neurons = [genotype:dirty_read({neuron, NId}) || NId <- NeuronIds], %% Load sensors for input count SensorIds = element(6, Cortex), %% #cortex.sensor_ids Sensors = [genotype:dirty_read({sensor, SId}) || SId <- SensorIds], %% Load actuators for output count ActuatorIds = element(7, Cortex), %% #cortex.actuator_ids Actuators = [genotype:dirty_read({actuator, AId}) || AId <- ActuatorIds], {ok, {Sensors, Neurons, Actuators}} end end. %% @private Build network from genotype structure build_network_from_structure({Sensors, Neurons, Actuators}) -> %% Calculate sizes InputSize = lists:sum([element(8, S) || S <- Sensors]), %% #sensor.vl OutputSize = lists:sum([element(7, A) || A <- Actuators]), %% #actuator.vl HiddenCount = length(Neurons), %% For now, create a simple feedforward approximation %% A proper implementation would recreate the exact topology HiddenSizes = case HiddenCount of 0 -> []; N when N < 10 -> [N]; N -> [N div 2, N div 2] end, %% Create network and copy weights from neurons Network = create_feedforward(InputSize, HiddenSizes, OutputSize), %% TODO: Copy actual weights from neuron records %% For now, the random weights from create_feedforward are used Network. %%============================================================================== %% Visualization Functions %%============================================================================== %% @doc Evaluate network and return all layer activations. %% %% Returns {Outputs, AllActivations} where AllActivations is a list of %% activation vectors for each layer (including input and output). -spec evaluate_with_activations(network(), [float()]) -> {Outputs :: [float()], Activations :: [[float()]]}. evaluate_with_activations(#network{layers = Layers, activation = Activation}, Inputs) -> {Outputs, Activations} = forward_propagate_with_activations(Layers, Inputs, Activation, [Inputs]), {Outputs, lists:reverse(Activations)}. %% @doc Get network topology information for visualization. %% %% Returns a map with layer sizes for rendering the network structure. -spec get_topology(network()) -> map(). get_topology(#network{layers = Layers}) -> %% Extract layer sizes from weight matrices LayerSizes = extract_layer_sizes(Layers), #{ layer_sizes => LayerSizes, num_layers => length(LayerSizes), total_neurons => lists:sum(LayerSizes), total_connections => count_connections(Layers) }. %% @doc Get visualization data for rendering the network. %% %% Combines topology, weights, and activations into a format suitable %% for frontend visualization. %% %% @param Network The network record %% @param Inputs Current input values (for activation display) %% @param InputLabels Optional labels for input neurons %% @returns Map with nodes, connections, and metadata -spec get_viz_data(network(), [float()], [binary()]) -> map(). get_viz_data(Network = #network{layers = Layers}, Inputs, InputLabels) -> %% Get activations for current inputs {Outputs, AllActivations} = evaluate_with_activations(Network, Inputs), %% Build layer sizes LayerSizes = extract_layer_sizes(Layers), %% Build node data with positions and activations Nodes = build_viz_nodes(LayerSizes, AllActivations, InputLabels), %% Build connection data with weights Connections = build_viz_connections(Layers, LayerSizes), #{ nodes => Nodes, connections => Connections, layer_sizes => LayerSizes, outputs => Outputs }. %% @private Forward propagate and collect all activations forward_propagate_with_activations([], Activations, _Activation, AllActivations) -> {Activations, AllActivations}; forward_propagate_with_activations([{Weights, Biases} | RestLayers], Inputs, Activation, AllActivations) -> Outputs = lists:zipwith( fun(NeuronWeights, Bias) -> Sum = dot_product(NeuronWeights, Inputs) + Bias, apply_activation(Sum, Activation) end, Weights, Biases ), forward_propagate_with_activations(RestLayers, Outputs, Activation, [Outputs | AllActivations]). %% @private Extract layer sizes from weight matrices extract_layer_sizes([]) -> []; extract_layer_sizes([{Weights, _Biases} | Rest]) -> %% First layer: input size is the width of weight matrix InputSize = length(hd(Weights)), %% All layers: output size is the height of weight matrix OutputSizes = [length(W) || {W, _} <- [{Weights, undefined} | Rest]], [InputSize | OutputSizes]. %% @private Count total connections count_connections(Layers) -> lists:sum([length(Weights) * length(hd(Weights)) || {Weights, _} <- Layers]). %% @private Build node data for visualization build_viz_nodes(LayerSizes, AllActivations, InputLabels) -> NumLayers = length(LayerSizes), lists:flatten( lists:zipwith3( fun(LayerIdx, LayerSize, Activations) -> Labels = case LayerIdx of 1 -> pad_labels(InputLabels, LayerSize); N when N == NumLayers -> output_labels(LayerSize); _ -> hidden_labels(LayerSize) end, build_layer_nodes(LayerIdx, LayerSize, Activations, Labels, NumLayers) end, lists:seq(1, NumLayers), LayerSizes, AllActivations ) ). %% @private Build nodes for a single layer build_layer_nodes(LayerIdx, LayerSize, Activations, Labels, NumLayers) -> lists:zipwith3( fun(NodeIdx, Activation, Label) -> #{ id => {LayerIdx, NodeIdx}, layer => LayerIdx, index => NodeIdx, activation => Activation, label => Label, type => layer_type(LayerIdx, NumLayers) } end, lists:seq(1, LayerSize), Activations, Labels ). %% @private Build connection data for visualization build_viz_connections(Layers, LayerSizes) -> {Connections, _} = lists:foldl( fun({Weights, _Biases}, {Acc, LayerIdx}) -> FromSize = lists:nth(LayerIdx, LayerSizes), ToSize = lists:nth(LayerIdx + 1, LayerSizes), LayerConns = [ #{ from => {LayerIdx, FromIdx}, to => {LayerIdx + 1, ToIdx}, weight => lists:nth(FromIdx, lists:nth(ToIdx, Weights)) } || ToIdx <- lists:seq(1, ToSize), FromIdx <- lists:seq(1, FromSize) ], {Acc ++ LayerConns, LayerIdx + 1} end, {[], 1}, Layers ), Connections. %% @private Determine layer type layer_type(1, _NumLayers) -> input; layer_type(N, N) -> output; layer_type(_, _) -> hidden. %% @private Pad labels to match layer size pad_labels(Labels, Size) when length(Labels) >= Size -> lists:sublist(Labels, Size); pad_labels(Labels, Size) -> Labels ++ lists:duplicate(Size - length(Labels), <<"">>). %% @private Generate output labels output_labels(6) -> [<<"L">>, <<"R">>, <<"F">>, <<"Spd">>, <<"Conf">>, <<"Aggr">>]; output_labels(Size) -> [list_to_binary("O" ++ integer_to_list(I)) || I <- lists:seq(1, Size)]. %% @private Generate hidden layer labels hidden_labels(Size) -> [list_to_binary("H" ++ integer_to_list(I)) || I <- lists:seq(1, Size)]. %%============================================================================== %% Serialization Functions %%============================================================================== %% @doc Serialize a network to a JSON-compatible map. %% %% The output format is suitable for JSON encoding and can be loaded %% in other runtimes (Python, JavaScript, etc.) for inference. %% %% Format: A map with keys "version", "activation", and "layers". %% The layers list contains maps with "weights" and "biases" keys. %% %% @param Network The network record %% @returns Map suitable for JSON encoding -spec to_json(network()) -> map(). to_json(#network{layers = Layers, activation = Activation}) -> #{ <<"version">> => 1, <<"activation">> => atom_to_binary(Activation, utf8), <<"layers">> => [ #{ <<"weights">> => Weights, <<"biases">> => Biases } || {Weights, Biases} <- Layers ] }. %% @doc Deserialize a network from a JSON-compatible map. %% %% Accepts the format produced by to_json/1. %% %% @param JsonMap Map from JSON decoding %% @returns {ok, Network} | {error, Reason} -spec from_json(map()) -> {ok, network()} | {error, term()}. from_json(#{<<"version">> := 1, <<"activation">> := ActivationBin, <<"layers">> := LayerMaps}) -> try Activation = binary_to_atom(ActivationBin, utf8), Layers = [ {maps:get(<<"weights">>, L), maps:get(<<"biases">>, L)} || L <- LayerMaps ], {ok, #network{layers = Layers, activation = Activation}} catch _:Reason -> {error, {invalid_network_format, Reason}} end; from_json(_) -> {error, unsupported_version}. %% @doc Serialize a network to binary using Erlang term format. %% %% This is more compact than JSON and preserves exact floating point values. %% Use this for Erlang-to-Erlang transfer or storage. %% %% @param Network The network record %% @returns Binary representation -spec to_binary(network()) -> binary(). to_binary(Network) -> term_to_binary(Network, [compressed]). %% @doc Deserialize a network from binary. %% %% @param Binary Binary from to_binary/1 %% @returns {ok, Network} | {error, Reason} -spec from_binary(binary()) -> {ok, network()} | {error, term()}. from_binary(Binary) when is_binary(Binary) -> try case binary_to_term(Binary) of #network{} = Network -> {ok, Network}; _ -> {error, invalid_network} end catch _:Reason -> {error, {deserialize_failed, Reason}} end; from_binary(_) -> {error, invalid_binary}.