├── .gitignore ├── .travis.yml ├── README.md ├── config └── config.exs ├── lib ├── ffnn │ ├── actuator.ex │ ├── constructor.ex │ ├── cortex.ex │ ├── exoself.ex │ ├── neuron.ex │ └── sensor.ex ├── simple_neuron.ex └── simplest_nn.ex ├── mix.exs ├── mix.lock └── test ├── ffnn ├── constructor_test.exs ├── example_ffnn.terms └── exoself_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - '1.5.2' 4 | otp_release: 5 | - '20.1.4' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neuroevolution In Elixir 2 | 3 | [![Build Status](https://travis-ci.org/myers/neuroevolution_in_elixir.svg?branch=master)](https://travis-ci.org/myers/neuroevolution_in_elixir) 4 | 5 | I'm currently reading [Handbook of Neuroevolution Through Erlang][book] and 6 | learning a lot. In it are examples showing how to build neural nets with 7 | Erlang. In order to drive home what I'm learning and to learn the Elixir 8 | syntax I'm translating the Erlang code to Elixir as I go. 9 | 10 | [book]: http://www.amazon.com/Handbook-Neuroevolution-Through-Erlang-Gene/dp/1461444624 11 | 12 | * https://github.com/CorticalComputer/Book_NeuroevolutionThroughErlang 13 | 14 | If you want to see a visualization of neural networks, you should checkout [TensorFlow's Playground](http://playground.tensorflow.org/) 15 | 16 | ## Simple Neuron 17 | 18 | iex -S mix 19 | SimpleNeuron.create 20 | SimpleNeuron.sense [1,2] 21 | 22 | ## Simplest NN 23 | 24 | iex -S mix 25 | SimplestNN.create 26 | send :cortex, :sense_think_act 27 | 28 | ## FFNN 29 | 30 | iex -S mix 31 | FFNN.Constructor.construct_genotype("/tmp/ffnn.terms", :rng, :pts, [1,3]) 32 | FFNN.Exoself.map("/tmp/ffnn.terms") 33 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | config :logger, :console, 14 | level: :info, 15 | format: "$date $time [$level] $metadata$message\n", 16 | metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /lib/ffnn/actuator.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Actuator do 2 | require Logger 3 | 4 | defstruct id: nil, cx_id: nil, name: nil, vl: nil, fanin_ids: [] 5 | 6 | @doc ~S""" 7 | When `gen/1` is executed it spawns the actuator element and immediately begins 8 | to wait for its initial state message. 9 | """ 10 | def gen(exoself_pid) do 11 | spawn(fn() -> loop(exoself_pid) end) 12 | end 13 | 14 | def loop(exoself_pid) do 15 | receive do 16 | {^exoself_pid, {id, cortex_pid, actuator_name, fanin_pids}} -> 17 | loop(id, cortex_pid, actuator_name, {fanin_pids, fanin_pids}, []) 18 | end 19 | end 20 | 21 | @doc ~S""" 22 | The actuator process gathers the control signals from the neurons, appending 23 | them to the accumulator. The order in which the signals are accumulated into 24 | a vector is in the same order as the neuron ids are stored within NIds. Once 25 | all the signals have been gathered, the actuator sends cortex the sync signal, 26 | executes its function, and then again begins to wait for the neural signals 27 | from the output layer by reseting the fanin_pids from the second copy of the 28 | list. 29 | """ 30 | def loop(id, cortex_pid, actuator_name, {[from_pid|fanin_pids], m_fanin_pids}, acc) do 31 | receive do 32 | {^from_pid, :forward, input} -> 33 | loop(id, cortex_pid, actuator_name, {fanin_pids, m_fanin_pids}, List.flatten([input, acc])) 34 | {^cortex_pid, :terminate} -> 35 | :ok 36 | end 37 | end 38 | def loop(id, cortex_pid, actuator_name, {[], m_fanin_pids}, acc) do 39 | apply(__MODULE__, actuator_name, [Enum.reverse(acc)]) 40 | send(cortex_pid, {self(), :sync}) 41 | loop(id, cortex_pid, actuator_name, {m_fanin_pids, m_fanin_pids}, []) 42 | end 43 | 44 | 45 | 46 | @doc ~S""" 47 | The pts actuation function simply prints to screen the vector passed to it. 48 | """ 49 | def pts(result) do 50 | Logger.debug "actuator:pts(result): #{inspect(result)}" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/ffnn/constructor.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Constructor do 2 | alias FFNN.Sensor 3 | alias FFNN.Actuator 4 | alias FFNN.Cortex 5 | alias FFNN.Neuron 6 | 7 | @doc """ 8 | The `construct_genotype` function accepts the name of the file to which we'll 9 | save the genotype, sensor name, actuator name, and the hidden layer density 10 | parameters. We have to generate unique Ids for every sensor and actuator. The 11 | sensor and actuator names are used as input to the create_sensor and 12 | create_actuator functions, which in turn generate the actual Sensor and 13 | Actuator representing tuples. We create unique Ids for sensors and actuators 14 | so that when in the future a NN uses 2 or more sensors or actuators of the 15 | same type, we will be able to differenti- ate between them using their Ids. 16 | After the Sensor and Actuator tuples are generated, we extract the NN’s input 17 | and output vector lengths from the sensor and actuator used by the system. The 18 | Input_VL is then used to specify how many weights the neurons in the input 19 | layer will need, and the Output_VL specifies how many neurons are in the 20 | output layer of the NN. After appending the HiddenLayerDensites to the now 21 | known number of neurons in the last layer to generate the full LayerDensities 22 | list, we use the create_NeuroLayers function to generate the Neuron 23 | representing tuples. We then update the Sensor and Actuator records with 24 | proper fanin and fanout ids from the freshly created Neuron tuples, compose 25 | the Cortex, and write the genotype to file. 26 | """ 27 | def construct_genotype(sensor_name, actuator_name, hidden_layer_densities) do 28 | construct_genotype(:ffnn, sensor_name, actuator_name, hidden_layer_densities) 29 | end 30 | 31 | def construct_genotype(file_name, sensor_name, actuator_name, hidden_layer_densities) do 32 | sensor = create_sensor(sensor_name) 33 | actuator = create_actuator(actuator_name) 34 | output_vl = actuator.vl 35 | layer_densities = List.insert_at(hidden_layer_densities, -1, output_vl) 36 | cx_id = {:cortex, generate_id()} 37 | neurons = create_neuro_layers(cx_id, sensor, actuator, layer_densities) 38 | input_layer = List.first(neurons) 39 | output_layer = List.last(neurons) 40 | fl_nids = Enum.map(input_layer, fn (n) -> n.id end) 41 | ll_nids = Enum.map(output_layer, fn (n) -> n.id end) 42 | n_ids = for n <- List.flatten(neurons), do: n.id 43 | sensor = %Sensor{sensor | cx_id: cx_id, fanout_ids: fl_nids} 44 | actuator = %Actuator{actuator | cx_id: cx_id, fanin_ids: ll_nids} 45 | cortex = create_cortex(cx_id, [sensor.id], [actuator.id], n_ids) 46 | genotype = List.flatten([cortex, sensor, actuator | neurons]) 47 | {:ok, file} = :file.open(file_name, :write) 48 | :lists.foreach(fn(x) -> :io.format(file, "~p.~n",[x]) end, genotype) 49 | :file.close(file) 50 | end 51 | 52 | @doc """ 53 | Every sensor and actuator uses some kind of function associated with it, a 54 | function that either polls the environment for sensory signals (in the case of 55 | a sensor) or acts upon the environment (in the case of an actuator). It is the 56 | function that we need to define and program before it is used, and the name of 57 | the function is the same as the name of the sensor or actuator itself. For 58 | example, the create_sensor/1 has specified only the rng sensor, because that 59 | is the only sensor function we’ve finished developing. The rng function has 60 | its own vl specification, which will determine the number of weights that a 61 | neuron will need to allocate if it is to accept this sensor's output vector. 62 | The same principles apply to the create_actuator function. Both, create_sensor 63 | and create_actuator function, given the name of the sensor or actuator, will 64 | return a record with all the specifications of that element, each with its own 65 | unique Id. 66 | """ 67 | def create_sensor(name) do 68 | case name do 69 | :rng -> 70 | %Sensor{id: {:sensor, generate_id()}, name: :rng, vl: 2} 71 | _ -> 72 | exit("System does not yet support a sensor by the name #{name}") 73 | end 74 | end 75 | 76 | def create_actuator(name) do 77 | case name do 78 | :pts -> 79 | %Actuator{id: {:actuator, generate_id()}, name: :pts, vl: 1} 80 | _ -> 81 | exit("System does not yet support a actuator by the name #{name}") 82 | end 83 | end 84 | 85 | @doc """ 86 | The function create_neuro_layers/3 prepares the initial step before starting 87 | the recursive create_neuro_layers/7 function which will create all the Neuron 88 | records. We first generate the place holder Input Ids "Plus" (Input_IdPs), 89 | which are tuples composed of Ids and the vector lengths of the incoming 90 | signals associated with them. The proper input_idps will have a weight list in 91 | the tuple instead of the vector length. Because we are only building NNs each 92 | with only a single Sensor and Actuator, the IdP to the first layer is composed 93 | of the single Sensor Id with the vector length of its sensory signal, likewise 94 | in the case of the Actuator. We then generate unique ids for the neurons in 95 | the first layer, and drop into the recursive create_neuro_layers/7 function. 96 | """ 97 | def create_neuro_layers(cx_id, sensor, actuator, layer_densities) do 98 | input_id_ps = [{sensor.id, sensor.vl}] 99 | tot_layers = length(layer_densities) 100 | [fl_neurons | next_lds] = layer_densities 101 | n_ids = for id <- generate_ids(fl_neurons, []), do: {:neuron, {1, id}} 102 | create_neuro_layers(cx_id, actuator.id, 1, tot_layers, input_id_ps, n_ids, next_lds, []) 103 | end 104 | def create_neuro_layers(cx_id, actuator_id, layer_index, tot_layers, input_id_ps, n_ids, [next_ld|lds], acc) do 105 | output_nids = for id <- generate_ids(next_ld,[]), do: {:neuron, {layer_index+1, id}} 106 | layer_neurons = create_neuro_layer(cx_id, input_id_ps, n_ids, output_nids, []) 107 | next_input_id_ps = for n_id <- n_ids, do: {n_id,1} 108 | create_neuro_layers(cx_id, actuator_id, layer_index+1, tot_layers, next_input_id_ps, output_nids, lds, [layer_neurons|acc]); 109 | end 110 | def create_neuro_layers(cx_id, actuator_id, tot_layers, tot_layers, input_id_ps, nids, [], acc) do 111 | output_ids = [actuator_id] 112 | layer_neurons = create_neuro_layer(cx_id, input_id_ps, nids, output_ids, []) 113 | Enum.reverse([layer_neurons|acc]) 114 | end 115 | @doc """ 116 | To create neurons from the same layer, all that is needed are the Ids for 117 | those neurons, a list of Input_IdPs for every neuron so that we can create the 118 | proper number of weights, and a list of Output_Ids. Since in our simple feed 119 | forward neural network all neurons are fully connected to the neurons in the 120 | next layer, the Input_IdPs and Output_Ids are the same for every neuron be- 121 | longing to the same layer. 122 | """ 123 | def create_neuro_layer(cx_id, input_id_ps, [id|n_ids], output_ids, acc) do 124 | neuron = create_neuron(input_id_ps, id, cx_id, output_ids) 125 | create_neuro_layer(cx_id, input_id_ps, n_ids, output_ids, [neuron|acc]) 126 | end 127 | def create_neuro_layer(_cx_id, _input_id_ps, [], _output_ids, acc), do: acc 128 | 129 | @doc """ 130 | Each neuron record is composed by the `create_neuron/3` function. The 131 | `create_neuron/3` function creates the Input list from the tuples 132 | [{Id,Weights}...] using the vector lengths specified in the place holder 133 | Input_IdPs. The `create_neural_input/2` function uses `create_neural_weights/2` to 134 | generate the random weights in the range of -0.5 to 0.5, adding the bias to 135 | the end of the list. 136 | """ 137 | def create_neuron(input_id_ps, id, cx_id, output_ids) do 138 | proper_input_id_ps = create_neural_input(input_id_ps, []) 139 | %Neuron{id: id, cx_id: cx_id, af: :tanh, input_id_ps: proper_input_id_ps, output_ids: output_ids} 140 | end 141 | 142 | def create_neural_input([{input_id, input_vl} | input_id_ps], acc) do 143 | weights = create_neural_weights(input_vl,[]) 144 | create_neural_input(input_id_ps, [{input_id, weights} | acc]) 145 | end 146 | def create_neural_input([], acc) do 147 | Enum.reverse([{:bias, :rand.uniform()-0.5 } | acc]) 148 | end 149 | 150 | def create_neural_weights(0, acc), do: acc 151 | def create_neural_weights(index, acc) do 152 | w = :rand.uniform() - 0.5 153 | create_neural_weights(index - 1, [w|acc]) 154 | end 155 | 156 | 157 | @doc """ 158 | The `generate_id/0` creates a unique Id using current time, the Id is a floating 159 | point value. The `generate_ids/2` function creates a list of unique Ids. 160 | """ 161 | def generate_ids(0, acc), do: acc 162 | def generate_ids(index, acc) do 163 | id = generate_id() 164 | generate_ids(index-1, [id|acc]) 165 | end 166 | def generate_id() do 167 | Ksuid.generate() 168 | end 169 | 170 | @doc """ 171 | The `create_cortex/4` function generates the record encoded genotypical 172 | representation of the cortex element. The Cortex element needs to know the Id 173 | of every Neuron, Sensor, and Actuator in the NN 174 | """ 175 | def create_cortex(cx_id, s_ids, a_ids, n_ids) do 176 | %Cortex{id: cx_id, sensor_ids: s_ids, actuator_ids: a_ids, n_ids: n_ids} 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/ffnn/cortex.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Cortex do 2 | require Logger 3 | 4 | defstruct id: nil, sensor_ids: [], actuator_ids: [], n_ids: [] 5 | 6 | @doc """ 7 | The `gen/1` function spawns the cortex element, which immediately starts to wait 8 | for a the state message from the same process that spawned it, exoself. The 9 | initial state message contains the sensor, actuator, and neuron PId lists. The 10 | message also specifies how many total Sense-Think-Act cycles the Cortex 11 | should execute before terminating the NN system. Once we implement the 12 | learning algorithm, the termination criteria will depend on the fitness of the 13 | NN, or some other useful property 14 | """ 15 | def gen(exoself_pid) do 16 | spawn fn() -> loop(exoself_pid) end 17 | end 18 | 19 | @doc """ 20 | The cortex’s goal is to synchronize the NN system such that when the actuators 21 | have received all their control signals, the sensors are once again triggered 22 | to gather new sensory information. Thus the cortex waits for the sync messages 23 | from the actuator PIds in its system, and once it has received all the sync 24 | messages, it triggers the sensors and then drops back to waiting for a new set 25 | of sync messages. The cortex stores 2 copies of the actuator PIds: the a_pids, 26 | and the Memorya_pids (Ma_pids). Once all the actuators have sent it the sync 27 | messages, it can restore the a_pids list from the Ma_pids. Finally, there is 28 | also the Step variable which decrements every time a full cycle of Sense- 29 | Think-Act completes, once this reaches 0, the NN system begins its termination 30 | and backup process. 31 | """ 32 | def loop(exoself_pid) do 33 | receive do 34 | {^exoself_pid, {id, s_pids, a_pids, n_pids}, total_steps} -> 35 | for s_pid <- s_pids, do: send(s_pid, {self(), :sync}) 36 | loop(id, exoself_pid, s_pids, {a_pids, a_pids}, n_pids, total_steps) 37 | end 38 | end 39 | def loop(id, exoself_pid, s_pids, {_a_pids, m_a_pids}, n_pids, 0) do 40 | Logger.debug "Cortex:#{inspect(id)} finished, now backing up and terminating." 41 | neuron_ids_and_weights = get_backup(n_pids, []) 42 | send(exoself_pid, {self(), :backup, neuron_ids_and_weights}) 43 | for lst <- [s_pids, m_a_pids, n_pids] do 44 | for pid <- lst, do: send(pid, {self(), :terminate}) 45 | end 46 | end 47 | def loop(id, exoself_pid, s_pids, {[a_pid|a_pids], m_a_pids}, n_pids, step) do 48 | receive do 49 | {^a_pid, :sync} -> 50 | loop(id, exoself_pid, s_pids, {a_pids, m_a_pids}, n_pids, step) 51 | :terminate -> 52 | Logger.info "Cortex:#{inspect(id)} is terminating." 53 | for lst <- [s_pids, m_a_pids, n_pids] do 54 | for pid <- lst, do: send(pid, {self(), :terminate}) 55 | end 56 | end 57 | end 58 | def loop(id, exoself_pid, s_pids, {[], m_a_pids}, n_pids, step) do 59 | for s_pid <- s_pids, do: send(s_pid, {self(), :sync}) 60 | loop(id, exoself_pid, s_pids, {m_a_pids, m_a_pids}, n_pids, step-1) 61 | end 62 | 63 | 64 | @doc """ 65 | During backup, cortex contacts all the neurons in its NN and requests for the 66 | neuron’s Ids and their Input_IdPs. Once the updated Input_IdPs from all the 67 | neurons have been accumulated, the list is sent to exoself for the actual 68 | backup and storage. 69 | """ 70 | def get_backup([n_pid|n_pids], acc) do 71 | send(n_pid, {self(), :get_backup}) 72 | receive do 73 | {^n_pid, n_id, weight_tuples} -> 74 | get_backup(n_pids, [{n_id, weight_tuples}|acc]) 75 | end 76 | end 77 | def get_backup([], acc), do: acc 78 | end 79 | -------------------------------------------------------------------------------- /lib/ffnn/exoself.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Exoself do 2 | alias FFNN.Sensor 3 | alias FFNN.Actuator 4 | alias FFNN.Cortex 5 | alias FFNN.Neuron 6 | require Logger 7 | 8 | @doc ~S""" 9 | The map/1 function maps the tuple encoded genotype into a process based 10 | phenotype. The map function expects for the cortex record to be the leading tuple 11 | in the tuple list it reads from the file_name. We create an ets table to map 12 | Ids to PIds and back again. Since the Cortex element contains all the Sensor 13 | Actuator, and Neuron Ids, we are able to spawn each neuron using its own gen 14 | function, and in the process construct a map from Ids to PIds. We then use 15 | link_cerebral_units to link all non Cortex elements to each other by sending 16 | each spawned pro- cess the information contained in its record, but with Ids 17 | converted to Pids where appropriate. Finally, we provide the Cortex process 18 | with all the PIds in the NN system by executing the link_cortex/2 function. 19 | Once the NN is up and running, exoself starts its wait until the NN has 20 | finished its job and is ready to backup. When the cortex initiates the backup 21 | process it sends exoself the updated Input_p_id_ps from its neurons. Exoself 22 | uses the update_genotype/3 function to update the old genotype with new 23 | weights, and then stores the updated version back to its file. 24 | """ 25 | def map() do 26 | map(:ffnn) 27 | end 28 | def map(file_name) do 29 | {:ok, genotype} = :file.consult(file_name) 30 | task = Task.async(fn -> map(file_name, genotype) end) 31 | Task.await(task) 32 | end 33 | def map(file_name, genotype) do 34 | ids_n_pids = :ets.new(:ids_n_pids, [:set, :private]) 35 | [cortex|cerebral_units] = genotype 36 | spawn_cerebral_units(ids_n_pids, Cortex, [cortex.id]) 37 | spawn_cerebral_units(ids_n_pids, Sensor, cortex.sensor_ids) 38 | spawn_cerebral_units(ids_n_pids, Actuator, cortex.actuator_ids) 39 | spawn_cerebral_units(ids_n_pids, Neuron, cortex.n_ids) 40 | link_cerebral_units(cerebral_units, ids_n_pids) 41 | link_cortex(cortex, ids_n_pids) 42 | cortex_pid = :ets.lookup_element(ids_n_pids, cortex.id, 2) 43 | 44 | receive do 45 | {^cortex_pid, :backup, neuron_ids_n_weights} -> 46 | u_genotype = update_genotype(ids_n_pids, genotype, neuron_ids_n_weights) 47 | {:ok, file} = :file.open(file_name, :write) 48 | :lists.foreach(fn(x) -> :io.format(file, "~p.~n", [x]) end, u_genotype) 49 | :file.close(file) 50 | Logger.debug("Finished updating to file: #{file_name}") 51 | end 52 | end 53 | 54 | 55 | @doc ~S""" 56 | We spawn the process for each element based on its type: cerebral_unit_type, and 57 | the gen function that belongs to the cerebral_unit_type module. We then enter 58 | the {Id, PId} tuple into our ETS table for later use. 59 | """ 60 | def spawn_cerebral_units(ids_n_pids, cerebral_unit_type, [id|ids]) do 61 | pid = apply(cerebral_unit_type, :gen, [self()]) 62 | :ets.insert(ids_n_pids, {id, pid}) 63 | :ets.insert(ids_n_pids, {pid, id}) 64 | spawn_cerebral_units(ids_n_pids, cerebral_unit_type, ids) 65 | end 66 | def spawn_cerebral_units(_ids_n_pids, _cerebral_unit_type, []) do 67 | true 68 | end 69 | 70 | @doc ~S""" 71 | The link_cerebral_units/2 converts the Ids to PIds using the created IdsNPids 72 | ETS table. At this point all the elements are spawned, and the processes are 73 | waiting for their initial states. 74 | """ 75 | def link_cerebral_units([%Sensor{} = sensor|cerebral_units], ids_n_pids) do 76 | sensor_pid = :ets.lookup_element(ids_n_pids, sensor.id, 2) 77 | cortex_pid = :ets.lookup_element(ids_n_pids, sensor.cx_id, 2) 78 | fanout_pids = for id <- sensor.fanout_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 79 | send sensor_pid, {self(), {sensor.id, cortex_pid, sensor.name, sensor.vl, fanout_pids}} 80 | link_cerebral_units(cerebral_units, ids_n_pids) 81 | end 82 | def link_cerebral_units([%Actuator{} = actuator|cerebral_units], ids_n_pids) do 83 | actuator_pid = :ets.lookup_element(ids_n_pids, actuator.id, 2) 84 | cortex_pid = :ets.lookup_element(ids_n_pids, actuator.cx_id, 2) 85 | fanin_pids = for id <- actuator.fanin_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 86 | send actuator_pid, {self(), {actuator.id, cortex_pid, actuator.name, fanin_pids}} 87 | link_cerebral_units(cerebral_units, ids_n_pids) 88 | end 89 | def link_cerebral_units([%Neuron{} = neuron|cerebral_units], ids_n_pids) do 90 | neuron_pid = :ets.lookup_element(ids_n_pids, neuron.id, 2) 91 | cortex_pid = :ets.lookup_element(ids_n_pids, neuron.cx_id, 2) 92 | input_pid_ps = convert_id_ps2pid_ps(ids_n_pids, neuron.input_id_ps, []) 93 | output_pids = for id <- neuron.output_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 94 | send neuron_pid, {self(), {neuron.id, cortex_pid, neuron.af, input_pid_ps, output_pids}} 95 | link_cerebral_units(cerebral_units, ids_n_pids) 96 | end 97 | def link_cerebral_units([], _ids_n_pids), do: :ok 98 | 99 | 100 | 101 | @doc ~S""" 102 | convert_id_ps2pid_ps/3 converts the IdPs 103 | tuples into tuples that use PIds instead of Ids, such that the Neuron will 104 | know which weights are to be associated with which incoming vector signals. 105 | The last element is the bias, which is added to the list in a non tuple form. 106 | Afterwards, the list is reversed to take its proper order. 107 | """ 108 | def convert_id_ps2pid_ps(_ids_n_pids, [{:bias, bias}], acc) do 109 | Enum.reverse([bias|acc]) 110 | end 111 | def convert_id_ps2pid_ps(ids_n_pids, [{id, weights}|fanin_id_ps], acc) do 112 | convert_id_ps2pid_ps(ids_n_pids, fanin_id_ps, [{:ets.lookup_element(ids_n_pids, id, 2), weights}|acc]) 113 | end 114 | 115 | @doc ~S""" 116 | The cortex is initialized to its proper state just as other elements. Because 117 | we have not yet implemented a learning algorithm for our NN system, we need to 118 | specify when the NN should shutdown. We do this by specifying the total number 119 | of cycles the NN should execute before terminating, which is 1000 in this 120 | case. 121 | """ 122 | def link_cortex(cortex, ids_n_pids) do 123 | cortex_pid = :ets.lookup_element(ids_n_pids, cortex.id, 2) 124 | sensor_pids = for id <- cortex.sensor_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 125 | actuator_pids = for id <- cortex.actuator_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 126 | neuron_pids = for id <- cortex.n_ids, do: :ets.lookup_element(ids_n_pids, id, 2) 127 | send(cortex_pid, {self(), {cortex.id, sensor_pids, actuator_pids, neuron_pids}, 1000}) 128 | end 129 | 130 | @doc ~S""" 131 | For every {neuron_id, p_id_ps} tuple the update_genotype/3 function extracts the 132 | neuron with the id: neuron_id, and updates its weights. The convert_p_id_ps2id_ps/3 133 | performs the conversion from PIds to Ids of every {PId, Weights} tuple in the 134 | Input_p_id_ps list. The updated genotype is then returned back to the caller. 135 | """ 136 | def update_genotype(ids_n_pids, genotype, [{neuron_id, p_id_ps}|weight_ps]) do 137 | Logger.debug("genotype: #{inspect(genotype)}") 138 | Logger.debug("neuron_id: #{inspect(neuron_id)}") 139 | ## FIXME: genotype is a list of maps/structs not tuples/records. Find replacement. 140 | neuron_index = Enum.find_index(genotype, fn(x) -> x.id == neuron_id end) 141 | neuron = Enum.at(genotype, neuron_index) 142 | #neuron = :lists.keyfind(neuron_id, 2, genotype) 143 | Logger.debug("p_id_ps: #{inspect(p_id_ps)}") 144 | input_id_ps = convert_p_id_ps2id_ps(ids_n_pids, p_id_ps, []) 145 | Logger.debug("neuron: #{inspect(neuron)}") 146 | updated_neuron = %Neuron{neuron | input_id_ps: input_id_ps} 147 | updated_genotype = List.replace_at(genotype, neuron_index, updated_neuron) 148 | Logger.debug("neuron: #{inspect(neuron)}") 149 | Logger.debug("updated_neuron: #{inspect(updated_neuron)}") 150 | Logger.debug("genotype: #{inspect(genotype)}") 151 | Logger.debug("updated_genotype: #{inspect(updated_genotype)}") 152 | update_genotype(ids_n_pids, updated_genotype, weight_ps) 153 | end 154 | def update_genotype(_ids_n_pids, genotype, []) do 155 | genotype 156 | end 157 | 158 | def convert_p_id_ps2id_ps(ids_n_pids, [{pid, weights}|input_id_ps], acc) do 159 | convert_p_id_ps2id_ps(ids_n_pids, input_id_ps, [{:ets.lookup_element(ids_n_pids, pid, 2), weights}|acc]) 160 | end 161 | def convert_p_id_ps2id_ps(_ids_n_pids, [bias], acc) do 162 | :lists.reverse([{:bias, bias}|acc]) 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /lib/ffnn/neuron.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Neuron do 2 | defstruct id: nil, cx_id: nil, af: nil, input_id_ps: [], output_ids: [] 3 | 4 | @doc ~S""" 5 | When gen/1 is executed it spawns the neuron element and immediately begins to 6 | wait for its initial state message. 7 | """ 8 | def gen(exoself_pid) do 9 | spawn(fn -> loop(exoself_pid) end) 10 | end 11 | 12 | def loop(exoself_pid) do 13 | receive do 14 | {^exoself_pid, {id, cortex_pid, af, input_id_ps, output_pids}} -> 15 | loop(id, cortex_pid, af, {input_id_ps, input_id_ps}, output_pids, 0) 16 | end 17 | end 18 | 19 | @doc ~S""" 20 | The neuron process waits for vector signals from all the processes that it's 21 | connected from, taking the dot product of the input and weight vectors, and 22 | then adding it to the accumulator. Once all the signals from input_pids are 23 | received, the accumulator contains the dot product to which the neuron then 24 | adds the bias and executes the activation function on. After fanning out the 25 | output signal, the neuron again returns to waiting for incoming signals. When 26 | the neuron receives the {cortex_pid, get_backup} message, it forwards to the 27 | cortex its full m_input_id_ps list, and its Id. Once the training/learning 28 | algorithm is added to the system, the m_input_id_ps would contain a full set of 29 | the most recent and updated version of the weights. 30 | """ 31 | def loop(id, cortex_pid, af, {[{input_pid, weights}|input_id_ps], m_input_id_ps}, output_pids, acc) do 32 | receive do 33 | {^input_pid, :forward, input} -> 34 | result = dot(input, weights, 0) 35 | loop(id, cortex_pid, af, {input_id_ps, m_input_id_ps}, output_pids, result+acc) 36 | {^cortex_pid, :get_backup} -> 37 | send(cortex_pid, {self(), id, m_input_id_ps}) 38 | loop(id, cortex_pid, af, {[{input_pid, weights}|input_id_ps], m_input_id_ps}, output_pids, acc) 39 | {^cortex_pid, :terminate} -> 40 | :ok 41 | end 42 | end 43 | def loop(id, cortex_pid, af, {[bias], m_input_id_ps}, output_pids, acc) do 44 | output = apply(__MODULE__, af, [acc+bias]) 45 | for output_pid <- output_pids, do: send(output_pid, {self(), :forward, [output]}) 46 | loop(id, cortex_pid, af, {m_input_id_ps, m_input_id_ps}, output_pids, 0) 47 | end 48 | def loop(id, cortex_pid, af, {[], m_input_id_ps}, output_pids, acc) do 49 | output = apply(af, [acc]) 50 | for output_pid <- output_pids, do: send(output_pid, {self(), :forward, [output]}) 51 | loop(id, cortex_pid, af, {m_input_id_ps, m_input_id_ps}, output_pids, 0) 52 | end 53 | 54 | def dot([i|input], [w|weights], acc), do: dot(input, weights, i*w+acc) 55 | def dot([], [], acc), do: acc 56 | 57 | @doc ~S""" 58 | Though in this current implementation the neuron has only the tanh/1 function 59 | available to it, we will later extend the system to allow different neurons to 60 | use different activation functions. 61 | """ 62 | def tanh(val), do: :math.tanh(val) 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/ffnn/sensor.ex: -------------------------------------------------------------------------------- 1 | defmodule FFNN.Sensor do 2 | defstruct id: nil, cx_id: nil, name: nil, vl: nil, fanout_ids: [] 3 | 4 | @doc ~S""" 5 | When `gen/1` is executed it spawns the sensor element and immediately begins 6 | to wait for its initial state message. 7 | """ 8 | def gen(exoself_pid) do 9 | spawn(fn() -> loop(exoself_pid) end) 10 | end 11 | 12 | def loop(exoself_pid) do 13 | receive do 14 | {^exoself_pid, {id, cortex_pid, sensor_name, vl, fanout_pids}} -> 15 | loop(id, cortex_pid, sensor_name, vl, fanout_pids) 16 | end 17 | end 18 | 19 | @doc ~S""" 20 | The sensor process accepts only 2 types of messages, both from the cortex. The 21 | sensor can either be triggered to begin gathering sensory data based on its 22 | sensory role, or terminate if the cortex requests so. 23 | """ 24 | def loop(id, cortex_pid, sensor_name, vl, fanout_pids) do 25 | receive do 26 | {^cortex_pid, :sync} -> 27 | sensory_vector = apply(__MODULE__, sensor_name, [vl]) 28 | for pid <- fanout_pids, do: send(pid, {self(), :forward, sensory_vector}) 29 | loop(id, cortex_pid, sensor_name, vl, fanout_pids) 30 | {^cortex_pid, :terminate} -> 31 | :ok 32 | end 33 | end 34 | 35 | @doc ~S""" 36 | `rng` is a simple random number generator that produces a vector of random 37 | values, each between 0 and 1. The length of the vector is defined by the vl, 38 | which itself is specified within the sensor record. 39 | """ 40 | def rng(vl), do: rng(vl, []) 41 | def rng(0, acc), do: acc 42 | def rng(vl, acc), do: rng(vl-1, [:rand.uniform()|acc]) 43 | end 44 | -------------------------------------------------------------------------------- /lib/simple_neuron.ex: -------------------------------------------------------------------------------- 1 | # From 6.1 Simulating A Neuron 2 | defmodule SimpleNeuron do 3 | require Logger 4 | 5 | # The create function spawns a single neuron, where the weights and the bias 6 | # are generated randomly to be between -0.5 and 0.5 7 | def create do 8 | weights = [:rand.uniform() - 0.5, :rand.uniform() - 0.5, :rand.uniform() - 0.5] 9 | Process.register(spawn(__MODULE__, :loop, [weights]), :neuron) 10 | end 11 | 12 | # The spawned neuron process accepts and input vector, prints it and the 13 | # weight vector to the screen, calculates the output, and then sends the 14 | # output to the contacting process. The outpu is also a vector of length one. 15 | def loop(weights) do 16 | receive do 17 | {from, input} -> 18 | Logger.info "**** Processing ****\n Input #{inspect input}\n Using Weigths #{inspect weights}" 19 | dot_product = dot(input, weights, 0) 20 | output = [:math.tanh(dot_product)] 21 | from |> send({:result, output}) 22 | loop(weights) 23 | end 24 | end 25 | 26 | # The dot product function that we use works on the assumption that the bias 27 | # is incorporated into the weight list as the last value in that list. After 28 | # calculation the dot product, the input list will empty out while the weight 29 | # list will still have the single biase value remaining, which we then add to 30 | # the accumulator. 31 | def dot([i | input], [w | weights], acc) do 32 | dot(input, weights, i*w+acc) 33 | end 34 | def dot([], [bias], acc) do 35 | acc + bias 36 | end 37 | 38 | # We use the sense function to contact the neuron and send it an input vector. 39 | # The sense function ensures that the signal we are sending is a vector of 40 | # length 2. 41 | def sense(signal) do 42 | case is_list(signal) and (length(signal) == 2) do 43 | true -> 44 | send(:neuron, {self(), signal}) 45 | receive do 46 | {:result, output} -> 47 | Logger.info " Output #{inspect output}" 48 | end 49 | false -> 50 | Logger.info "The signal must be a list of length 2" 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/simplest_nn.ex: -------------------------------------------------------------------------------- 1 | # From 6.2 A One Neuron Neural Network 2 | defmodule SimplestNN do 3 | require Logger 4 | 5 | # The create function first generates 3 weights, with the 3rd weigth being the 6 | # bias. The neuron is spawed first, and is then sent the pids of the sensor 7 | # and actuator that it's connected with. Then the cortex element is 8 | # registered and provided with the pids of all the elements in the NN system. 9 | def create do 10 | weights = [:rand.uniform() - 0.5, :rand.uniform() - 0.5, :rand.uniform() - 0.5] 11 | n_pid = spawn __MODULE__, :neuron, [weights, nil, nil] 12 | s_pid = spawn __MODULE__, :sensor, [n_pid] 13 | a_pid = spawn __MODULE__, :actuator, [n_pid] 14 | n_pid |> send({:init, s_pid, a_pid}) 15 | Process.register(spawn(__MODULE__, :cortex, [s_pid, n_pid, a_pid]), :cortex) 16 | end 17 | 18 | # After the neuron finishes setting its s_pid and a_pid to that of the senor 19 | # and actuator respectively, it starts waiting for the incoming signals. The 20 | # neuron expects a vector of length 2 as input, and as soon as the input 21 | # arrives, the neuron processes the signal and passes the output vector to the 22 | # outgoing a_pid 23 | def neuron(weights, _s_pid, a_pid) do 24 | receive do 25 | {s_pid, :forward, input} -> 26 | Logger.info "**** Thinking ****\n Input: #{inspect input}\n with weights: #{inspect weights}" 27 | dot_product = dot(input, weights, 0) 28 | output = [:math.tanh(dot_product)] 29 | a_pid |> send({self(), :forward, output}) 30 | neuron(weights, s_pid, a_pid) 31 | {:init, new_s_pid, new_a_pid} -> 32 | neuron(weights, new_s_pid, new_a_pid) 33 | :terminate -> 34 | :ok 35 | end 36 | end 37 | 38 | # The dot function take a dot product of two vectors, it can operate on a 39 | # weight vector with and without a bias. When there is no bias in the weight 40 | # list, both the input vector and the weight vector are of the same length. 41 | # When bias is present, then when the input list empties out, the weights list 42 | # still has 1 value remaining, its bias. 43 | def dot([i | input], [w | weights], acc) do 44 | dot(input, weights, i*w+acc) 45 | end 46 | def dot([], [bias], acc) do 47 | acc + bias 48 | end 49 | 50 | # The sensor function waits to be triggered by the cortex element, and then 51 | # produces a random vector of length 2, which it passes to the connected 52 | # neuron. In a proper system the sensory signal would not be a random vector 53 | # but instead would be produced by a function associated with the sensor, a 54 | # function that for example reads and vector-encodes a signal coming from a 55 | # GPS attached to a robot 56 | def sensor(n_pid) do 57 | receive do 58 | :sync -> 59 | sensory_signal = [:rand.uniform, :rand.uniform] 60 | Logger.info "**** Sensing ****\n Signal from the env #{inspect sensory_signal}" 61 | n_pid |> send({self(), :forward, sensory_signal}) 62 | :terminate -> 63 | :ok 64 | end 65 | end 66 | 67 | # The actuator function waits for a control signal coming from a neuron. As 68 | # soon as the signal arrives, the actuator executes its function pts/1, which 69 | # prints the value to the screen. 70 | def actuator(_n_pid) do 71 | receive do 72 | {n_pid, :forward, control_signal} -> 73 | pts(control_signal) 74 | actuator(n_pid) 75 | :terminate -> 76 | :ok 77 | end 78 | end 79 | 80 | def pts(control_signal) do 81 | Logger.info "**** Acting ****\n Using: #{inspect control_signal} to act on env" 82 | end 83 | 84 | # The cortex function triggers the sensor to action when commanded by the 85 | # user. This process also has all the pids of the elements in the NN system, 86 | # so that it can terminate the whole system when requested. 87 | def cortex(s_pid, n_pid, a_pid) do 88 | receive do 89 | :sense_think_act -> 90 | s_pid |> send(:sync) 91 | cortex(s_pid, n_pid, a_pid) 92 | :terminate -> 93 | s_pid |> send(:terminate) 94 | n_pid |> send(:terminate) 95 | a_pid |> send(:terminate) 96 | :ok 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule NeuroevolutionInElixir.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ app: :neuroevolution_in_elixir, 6 | version: "0.0.1", 7 | elixir: "~> 1.5.2", 8 | deps: deps() ] 9 | end 10 | 11 | # Configuration for the OTP application 12 | def application do 13 | [applications: [:logger]] 14 | end 15 | 16 | # Returns the list of dependencies in the format: 17 | # { :foobar, git: "https://github.com/elixir-lang/foobar.git", tag: "0.1" } 18 | # 19 | # To specify particular versions, regardless of the tag, do: 20 | # { :barbat, "~> 0.1", github: "elixir-lang/barbat" } 21 | defp deps do 22 | [ 23 | {:temp, "~> 0.4"}, 24 | {:ksuid, "~> 0.1.2"} 25 | ] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"ksuid": {:hex, :ksuid, "0.1.2", "7c32d822aecfada9e38eb0398f9bae6018e91eb5097e8b0315842bd8148490a8", [], [], "hexpm"}, 2 | "temp": {:hex, :temp, "0.4.3", "b641c3ce46094839bff110fdb64162536d640d9d47ca2c37add9104a2fa3bd81", [], [], "hexpm"}} 3 | -------------------------------------------------------------------------------- /test/ffnn/constructor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FFNN.ConstructorTest do 2 | use ExUnit.Case 3 | 4 | test "creates NN and writes it to disk" do 5 | tmp_path = Temp.mkdir! "ConstructorTest" 6 | genome_path = Path.join(tmp_path, "ffnn.terms") 7 | assert FFNN.Constructor.construct_genotype(genome_path, :rng, :pts, [1,3]) == :ok 8 | assert File.exists?(genome_path) 9 | File.rm_rf!(tmp_path) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/ffnn/example_ffnn.terms: -------------------------------------------------------------------------------- 1 | #{'__struct__' => 'Elixir.FFNN.Cortex', 2 | actuator_ids => [{actuator,<<"0wb2iIs2P8lrAZmUup4wIttUMWX">>}], 3 | id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 4 | n_ids => 5 | [{neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}}, 6 | {neuron,{2,<<"0wb2iLzGPZN4CfCpJcAYDIoSk8D">>}}, 7 | {neuron,{2,<<"0wb2iG0L0qrCUM5e0X1ZyjlCmhi">>}}, 8 | {neuron,{2,<<"0wb2iH1YuLs9ze7Dj4qqI0ZDt3b">>}}, 9 | {neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}], 10 | sensor_ids => [{sensor,<<"0wb2iEgdXSINDKYKrPKQltEzFmy">>}]}. 11 | #{'__struct__' => 'Elixir.FFNN.Sensor', 12 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 13 | fanout_ids => [{neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}}], 14 | id => {sensor,<<"0wb2iEgdXSINDKYKrPKQltEzFmy">>}, 15 | name => rng,vl => 2}. 16 | #{'__struct__' => 'Elixir.FFNN.Actuator', 17 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 18 | fanin_ids => [{neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}], 19 | id => {actuator,<<"0wb2iIs2P8lrAZmUup4wIttUMWX">>}, 20 | name => pts,vl => 1}. 21 | #{'__struct__' => 'Elixir.FFNN.Neuron',af => tanh, 22 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 23 | id => {neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}}, 24 | input_id_ps => 25 | [{{sensor,<<"0wb2iEgdXSINDKYKrPKQltEzFmy">>}, 26 | [-0.10748074766987015,-0.19052864311486084]}, 27 | {bias,-0.25817981192718775}], 28 | output_ids => 29 | [{neuron,{2,<<"0wb2iH1YuLs9ze7Dj4qqI0ZDt3b">>}}, 30 | {neuron,{2,<<"0wb2iG0L0qrCUM5e0X1ZyjlCmhi">>}}, 31 | {neuron,{2,<<"0wb2iLzGPZN4CfCpJcAYDIoSk8D">>}}]}. 32 | #{'__struct__' => 'Elixir.FFNN.Neuron',af => tanh, 33 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 34 | id => {neuron,{2,<<"0wb2iLzGPZN4CfCpJcAYDIoSk8D">>}}, 35 | input_id_ps => 36 | [{{neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}},[0.34317117014747645]}, 37 | {bias,0.14192089401414976}], 38 | output_ids => [{neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}]}. 39 | #{'__struct__' => 'Elixir.FFNN.Neuron',af => tanh, 40 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 41 | id => {neuron,{2,<<"0wb2iG0L0qrCUM5e0X1ZyjlCmhi">>}}, 42 | input_id_ps => 43 | [{{neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}},[-0.3114894811702784]}, 44 | {bias,-0.01136113872524025}], 45 | output_ids => [{neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}]}. 46 | #{'__struct__' => 'Elixir.FFNN.Neuron',af => tanh, 47 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 48 | id => {neuron,{2,<<"0wb2iH1YuLs9ze7Dj4qqI0ZDt3b">>}}, 49 | input_id_ps => 50 | [{{neuron,{1,<<"0wb2iI24bp8AoRKP3A47DRYVlhT">>}},[-0.25054527918670055]}, 51 | {bias,0.18466905124197674}], 52 | output_ids => [{neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}]}. 53 | #{'__struct__' => 'Elixir.FFNN.Neuron',af => tanh, 54 | cx_id => {cortex,<<"0wb2iEY1IPAqDLpg9YKMfz1jDKL">>}, 55 | id => {neuron,{3,<<"0wb2iLZyye7eXodOKh3wpQXRvK7">>}}, 56 | input_id_ps => 57 | [{{neuron,{2,<<"0wb2iH1YuLs9ze7Dj4qqI0ZDt3b">>}},[0.18524722414437467]}, 58 | {{neuron,{2,<<"0wb2iG0L0qrCUM5e0X1ZyjlCmhi">>}},[0.049818116812030744]}, 59 | {{neuron,{2,<<"0wb2iLzGPZN4CfCpJcAYDIoSk8D">>}},[-0.19019561941110374]}, 60 | {bias,0.16233742261502115}], 61 | output_ids => [{actuator,<<"0wb2iIs2P8lrAZmUup4wIttUMWX">>}]}. 62 | -------------------------------------------------------------------------------- /test/ffnn/exoself_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FFNN.ExoselfTest do 2 | use ExUnit.Case 3 | 4 | test "load example genome file" do 5 | tmp_path = Temp.mkdir! "ExoselfTest" 6 | genome_path = Path.join(tmp_path, "ffnn.terms") 7 | File.cp Path.join(__DIR__, "example_ffnn.terms"), genome_path 8 | assert FFNN.Exoself.map(genome_path) == :ok 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------