Skip to content

About

Final Year Project, Evolving Spiking Neural P Systems with Genetic Algorithms

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

Spiking Neural P Systems evolved with the use of Genetic Algorithms

Researched and Developed by Michael Stachowicz, 15068126 - MMU

This project targets .NET 10.

Thanks to Newtonsoft for the JSON parser libraries which are provided with this software

How to build

From the SNP_Evolution folder:

dotnet run --project Cli              # start the console menu
dotnet test                           # run the test suite
dotnet test --filter "Speed!=slow"    # the fast set, golden runs included, in seconds

Warnings are errors, and dotnet format --verify-no-changes must pass; every project shares Directory.Build.props and .editorconfig. Tests that run searches, time-limited bounded checks or external tools are marked [Slow]. CI (.github/workflows/ci.yml) builds, checks formatting and runs the fast set, then the slow set, on every push and pull request, on Linux with iverilog; the Metal, Uppaal and norse tests skip there.

The golden runs in SNP_Evolution.Tests/Golden run fixed-seed commands (evolve, evolve-parts, compile, compose, verify and the three exports) and every engine on the reference networks and parts, and compare their output with the files in Golden/Expected exactly, evaluation counts included. A change that alters one fails them. When a change is meant to alter results, rerun with SNP_UPDATE_GOLDEN=1 dotnet test --filter Golden and say in the pull request why the files changed.

Installing the command

To run the program from any folder as snp-evolution, install it as a .NET global tool. On macOS, from the repository root:

sh install-snp.sh

Run the script again after changing the code to reinstall the latest build. It adds ~/.dotnet/tools to your PATH in ~/.zshrc if needed. Elsewhere, from the SNP_Evolution folder:

dotnet pack Cli -c Release
dotnet tool install --global --add-source ./nupkg SNP_Evolution

To remove the command, run dotnet tool uninstall --global SNP_Evolution. Evolved networks are saved under the folder you run the command from.

Using the menu

Run the program with no command (dotnet run --project Cli, snp-evolution, or by double-clicking the built executable on Windows) to open the menu. Move with the arrow keys and press Enter to select, or press an option's number. ESC, Backspace or the left arrow goes back a level. Every page stays open until you leave it. The top of each screen shows the current task, search settings and simulation settings.

The menu and the commands are one program and share their code: every menu entry that runs something runs one of the commands below, the same way it runs from the command line. Choosing an entry opens the command's form:

  • Run parses the form as a command line and runs the command. The form shows that line, $ snp-evolution ..., so the same run can be scripted. It lists the form's options and every setting the command takes whose menu value is not the default. While it runs, ESC stops it early: each search finishes the generation it is on and keeps its best so far, a command running several searches or proofs (evolve-parts, verify, benchmark, reach) starts no more, and the command reports what it had. A run that has printed nothing for 15 seconds says it is still working.
  • The command's own options follow, such as --target, --seed or --part. Pick one to type its value, or to choose from its values. Leave a value empty to take the option off. Some entries fill options from the settings, such as the selected target or task.
  • Then a page for each group of settings the command takes (Evolution, Search, Simulation, Benchmark). Settings changed on a form apply to that command's runs only. Settings changed in Settings apply to every run.

Commands start from the menu's settings where the command line starts from the defaults. The pages:

  • Evolve a system

    • Match the target or For the task: evolve for the task chosen in Settings > Evolution, either a target or a suite task. A target is one of three kinds:
      • Set: the numbers a run can produce, in any order, e.g. 2,4,6,8. Each run gives one number: the steps between the output neuron's first two spikes.
      • Sequence: the gaps between successive output spikes, in order and with repeats, e.g. 1,1,2,3,5,8,13.
      • Binary word: the output spike train step by step, 1 for a spike, e.g. 001001001001.
    • Starting from the natural numbers or even numbers network: evolve --start natural or --start even, which evolves a hand-designed network towards the target. To evolve only its rules, as in the original paper, pick a "rule expressions only" algorithm.
    • Suggest settings: advise for the selected task, then an offer to apply the suggestions to the settings. To apply them and evolve in one go, set --advise on on the evolve form; --pilot on picks the algorithm with a quick pilot.
    • Compile a target, then shrink it: compile (see Compile, then shrink).
    • Compare setups on a target: reach.
    • Redo a saved run: after an evolve run you can save it under a name. Saved runs can be run again as they were, have their settings loaded to change first, or be deleted.
  • Library parts: evolve-parts (see Evolving library parts), compose for a suite task, verify, and parts. Browse the kept parts lists the parts in the library folder; choosing one shows it in full.

  • Run a network: run on the natural numbers or even numbers network, or on a saved network file.

  • Export: export-verilog, export-nir and export-uppaal (see Exporting to hardware).

  • Benchmark: benchmark, which also saves the table to a run folder; select, which offers to use the winner from then on; and the tasks and algorithms lists.

  • Settings: grouped into:

    • Evolution: task, target, fitness function, algorithm, population, mutation rate, generations, maximum neurons and experimental rules.
    • Search: iterative evolution and its stages, stagnation recovery, genome limits (delay, spikes produced, initial spikes, duplicate neurons), lexicase parents, modules (build from modules, freeze, triggered, incubation, and module files that start the library), the evaluations per library part, and composition search (part library folder, part copies, glue, proposing parts when stalled, and starting from the hand-built parts).
    • Simulation: engine, steps, runs per network, rule form, output timing and the hardware profile.
    • Benchmark: seeds, evaluations per run and population.

    Reset to defaults restores the default configuration. Every setting is also an option of the commands that use it, so a run set up in the menu can be repeated from a script.

Sequence and binary targets have some limits:

  • Only a network that gives the output on every sampled run solves the target. Spike trains are always sampled, even on the exhaustive engine.
  • Runs are lengthened automatically to fit the whole target.
  • Small SN P systems produce spike trains that eventually repeat. An evolved network matches the numbers you typed but need not continue the pattern after them. For example, one evolved network for 1,1,2,3,5,8,13 continued with 6,8.
  • The default algorithm, MAP-Elites, evolves structure as well as rules. If you pick a rule-only algorithm for a run from scratch, evolve warns that a random network's structure never changes. Long or irregular targets need a larger population than the default.

Commands

Every menu run is one of these commands, so it also runs without the menu, from scripts. Each command declares its options, and an unknown option, a missing value or a value the option cannot take is refused with a message naming it. Commands exit with 0 on success, 1 for a bad command line or input they cannot use, 2 when a run solved nothing or a proof found a counterexample, and 3 when an outside tool's check differs from our engine. Options in brackets are optional; NAME matches any task, search or contract whose name contains it, ignoring case, unless one name is exactly NAME.

Command What it does Options
evolve Evolves a network for a target or a suite task and saves the run; exits with 2 when no network solved it. [--target VALUES] [--kind set|sequence|binary] [--task NAME] [--start scratch|natural|even] [--seed N] [--advise on|off] [--pilot on|off] [--generations N] [--population N] [--neurons N] [--mutation-rate X] [--experimental on|off] [--fitness NAME] [--patience N] [--recovery on|off] [--iterative on|off] [--first-stage N] [--stage-step N] [--max-delay N] [--max-produce N] [--initial-spikes N] [--duplicates on|off] [--lexicase on|off] [--profile hardware|none] [--rule-form legacy|standard|mixed] [--modules on|off] [--freeze on|off] [--triggered on|off] [--incubate N] [--evaluations N] [--library DIR] [--hand-built on|off|leaves] [--propose on|off] [--proposal-budget N] [--max-parts N] [--glue N] [--glue-weight X] [--module-files a.json,b.json] [--algorithm NAME] [--simulator NAME] [--steps N] [--repetitions N] [--timing legacy|interval]
advise Prints the settings the advisor suggests for a target or a suite task, without evolving. [--target VALUES] [--kind set|sequence|binary] [--task NAME] [--generations N] [--population N] [--neurons N] [--mutation-rate X] [--experimental on|off] [--fitness NAME] [--patience N] [--recovery on|off] [--iterative on|off] [--first-stage N] [--stage-step N] [--max-delay N] [--max-produce N] [--initial-spikes N] [--duplicates on|off] [--lexicase on|off] [--profile hardware|none] [--rule-form legacy|standard|mixed] [--modules on|off] [--freeze on|off] [--triggered on|off] [--incubate N] [--evaluations N] [--library DIR] [--hand-built on|off|leaves] [--propose on|off] [--proposal-budget N] [--max-parts N] [--glue N] [--glue-weight X] [--module-files a.json,b.json] [--algorithm NAME] [--simulator NAME] [--steps N] [--repetitions N] [--timing legacy|interval]
compile Compiles a sequence target's recurrence or a set target's register program into a network that is correct by construction, then shrinks it. --target VALUES [--kind set|sequence|binary] [--program FILE] [--generations N] [--lexicase on|off] [--shrink N] [--seed N] [--population N]
reach Runs each setup on seeds 1 to N with the same budget and compares how far into the target they get. --target VALUES --evaluations N [--kind set|sequence|binary] [--setups flat,modules,composition] [--seeds N] [--charge-parts on|off] [--generations N] [--population N] [--neurons N] [--mutation-rate X] [--experimental on|off] [--fitness NAME] [--patience N] [--recovery on|off] [--iterative on|off] [--first-stage N] [--stage-step N] [--max-delay N] [--max-produce N] [--initial-spikes N] [--duplicates on|off] [--lexicase on|off] [--profile hardware|none] [--rule-form legacy|standard|mixed] [--modules on|off] [--freeze on|off] [--triggered on|off] [--incubate N] [--library DIR] [--hand-built on|off|leaves] [--propose on|off] [--proposal-budget N] [--max-parts N] [--glue N] [--glue-weight X] [--module-files a.json,b.json] [--algorithm NAME] [--simulator NAME] [--steps N] [--repetitions N] [--timing legacy|interval]
evolve-parts Evolves, verifies, shrinks and saves a part for each first-part contract the library has no part for, and reports robustness to jitter. [--seed N] [--budget N] [--only "NAME,NAME"] [--library DIR] [--engine exact|sampled] [--configurations N] [--redo on|off] [--profile hardware|none] [--robust J] [--staged on|off] [--route both|search|compile]
compose Composition search for one suite task; a solved contract is promoted to a part and the library saved. --task NAME [--seed N] [--generations N] [--population N] [--neurons N] [--mutation-rate X] [--experimental on|off] [--fitness NAME] [--patience N] [--recovery on|off] [--iterative on|off] [--first-stage N] [--stage-step N] [--max-delay N] [--max-produce N] [--initial-spikes N] [--duplicates on|off] [--lexicase on|off] [--profile hardware|none] [--rule-form legacy|standard|mixed] [--modules on|off] [--freeze on|off] [--triggered on|off] [--incubate N] [--evaluations N] [--library DIR] [--hand-built on|off|leaves] [--propose on|off] [--proposal-budget N] [--max-parts N] [--glue N] [--glue-weight X] [--module-files a.json,b.json] [--algorithm NAME] [--simulator NAME] [--steps N] [--repetitions N] [--timing legacy|interval]
verify Proves each part's contract for every input up to a bound, raised until the time per part runs out, and records it in the part's file. [--part FILE] [--library DIR] [--only "NAME,NAME"] [--seconds N] [--bound N]
parts Lists the parts kept in the library, or shows each part asked for in full: contract, ports, cost, proof, origin, what it reads on each case and its network. [--library DIR] [--only "NAME,NAME"] [--part FILE]
export-verilog Writes a deterministic network as Verilog with a testbench, and co-simulates it under iverilog when installed. [--part FILE] [--network FILE] [--steps N] [--out DIR] [--check on|off]
export-nir Writes a hardware-profile network for tools/snp_nir.py, which makes the NIR file and co-simulates it in norse. [--part FILE] [--network FILE] [--steps N] [--out DIR]
export-uppaal Writes a part as Uppaal timed automata with queries for its contract, and model-checks them with verifyta when installed. --part FILE [--out DIR] [--check on|off]
benchmark Runs every matching search on every matching suite task over several seeds, with a budget of evaluations per run, and prints the table. [--task NAME] [--algorithm NAME] [--engine exact|sampled] [--configurations N] [--lexicase on|off] [--budget N] [--seeds N] [--population N] [--repetitions N] [--library DIR] [--hand-built on|off|leaves]
select Picks the best of the matching searches for one suite task by successive halving. --task NAME [--algorithm NAME] [--engine exact|sampled] [--configurations N] [--lexicase on|off] [--budget N] [--seeds N] [--population N] [--repetitions N] [--library DIR] [--hand-built on|off|leaves]
run Runs a saved or reference network and prints the numbers it generated and one run's spike train, or its score on the task when it has inputs. --network FILE|natural|even [--target VALUES] [--kind set|sequence|binary] [--task NAME] [--seed N] [--simulator NAME] [--steps N] [--repetitions N] [--timing legacy|interval]
tasks Lists the suite tasks.
algorithms Lists the searches.

Understanding the evolution outputs in the console

When evolving, the console shows the best network after every generation: its neurons, its rules, what it did on the task, and its fitness. For each kind of task, that line shows:

  • Generator: the distinct numbers it produced.
  • Sequence: intervals [1,1,2,3] / [1,1,2,3,5] (output / target).
  • Binary word: spikes 0010010 / 0010010.
  • Function: f(3)={3}/3 (outputs / expected).
  • Acceptor: the numbers it accepted.

Sequence and binary lines add "(varies between runs)" when the network is nondeterministic. With the MAP-Elites algorithm, the end of the run also lists the fittest network of each size.

Running a network shows the numbers it generated, one run's spike train with the intervals between its spikes, and how long the run took. For a network with input neurons, it shows its score on the selected task instead of the spike train. Evolved networks are saved to a timestamped folder inside runs/ at the root of the repository (or of the working directory outside one), named in the message at the end of a run; git ignores it. To run one again, choose Run a network > A network from a file and give that path, or run run --network ../runs/6234234242322/TargetNet.json from the SNP_Evolution folder. Test Data/, Combined Data/ and CompositeLowHighGen/ hold earlier runs.

Rule forms

Two rule forms are supported, and one network can mix them:

  • Legacy (aa -> a, the original program's rules): when the expression matches the spike count, the neuron empties and sends one spike. A delayed rule sends its spike at once, then holds the neuron for d steps before emptying it.
  • Standard (E/a^c -> a^p;d, as in the SN P literature): when the expression matches and the neuron holds at least c spikes, the rule consumes exactly c spikes and sends p along each synapse. A delayed rule closes the neuron for d steps: spikes sent to it in that time are lost, and it fires when it reopens. The notation leaves out E/ when E only matches a^c, so aaa -> aa consumes 3 spikes and sends 2.
  • Axonal delays (a+ -> a;2 axonal): either form's delay can instead be axonal, as integrate-and-fire hardware has it. The rule consumes at once, the neuron stays open and can fire again, and its spikes reach the targets d steps later. Only the hardware profile (see Exporting to hardware) makes these; the Metal engine runs such networks on the CPU.

The Rule Form setting (Legacy, Standard or Mixed, --rule-form) picks the form of the rules evolution creates. The Output Timing setting (--timing) picks how the output neuron's spikes become a number. Legacy counts every non-spiking step from the start, as the original program did. Interval counts the steps between the first two spikes, as the literature does. Standard rules and Interval timing are the defaults, as the parts, compiler and verifier use. To reproduce the original program, pass --rule-form legacy --timing legacy, or pick them in Settings > Simulation. The suite's Generate tasks keep Mixed rules and Legacy timing, so benchmarks stay comparable with the original program. JSON files gain optional Consume, Produce, Axonal and IsInput fields, and older files load as legacy rules.

Tasks

A task says what a network should do (Specs/Tasks/ITask):

  • Generator: with no input, produce exactly a set of numbers, such as a set target from the settings.
  • Sequence: with no input, space the output spikes by the target's gaps, in order (SequenceTask). Each gap that is wrong but close earns partial credit.
  • Binary word: with no input, spike exactly on the target's 1s (SpikeWordTask). Scored by balanced accuracy over the 1s and 0s.

The last two read the whole output spike train (Readout.SpikeTrain), not just the first two spikes. OutputTarget turns a typed target into the matching task.

  • Function: read numbers on the input neurons, each as two spikes n steps apart, and output f(n). Examples are n + 1, 2n and n1 + n2. Wrong but close outputs earn partial credit.

  • Acceptor: read a number on the input neuron and halt if and only if it belongs to the set. Scored by balanced accuracy.

  • Contract: behave as a part with a start trigger, one or more done triggers and typed data ports (interval, count, trigger or binary), as a Contract in Specs/Contracts/ describes (ContractTask). Each case is checked against four rules: nothing is sent before start, done fires exactly once with the right outputs, every neuron ends with the spikes it started with, and done fires within the maximum latency. It reads the named port neurons (Readout.Ports), which the exhaustive engine follows over every computation. ReferenceParts holds a hand-built delay and register that meet their contracts.

  • Streaming: a controller with no start or done (StreamingTask). A long sensor train arrives on the one input neuron, drawn from a fixed seed per case so every network sees the same trains, and the output's spike train is judged window by window, one check per window. Scored by balanced accuracy over windows that want output and windows that want silence, so a silent network earns 0.5 and one that copies its input less; it counts as solved only at 1. The debouncer answers each burst of at least m spikes within w steps with exactly one spike, and glitches of fewer spikes with none. The rate detector fires at least once in every window of a fast stretch (at least m spikes per w steps) and never in a slow one; the first window after each change of rate is not judged.

TaskSuite holds a benchmark suite of these tasks, and the settings menu can select any of them. It includes eight arithmetic contracts (ArithmeticParts): n1 - n2 (with n1 >= n2), n1 x n2, n1 div n2 with remainder, and n1 < n2 with two done branches. Each comes in count encoding and in binary with 4-bit operands and an 8-bit product. Cases include zero operands and one larger pair.

Engines

  • Auto: fastest available is the default. It is the GPU engine below on a Mac, and CPU, all cores elsewhere.
  • CPU, all cores and CPU, single thread sample a number of random runs of each network, as before.
  • Exhaustive (exact outputs) follows every possible computation, merging those that reach the same configuration. Its outputs are therefore the exact set the network can produce within the step limit, with no luck in the score. An exact solution is accepted without retesting. If a network's computations branch too widely, the engine samples it instead; so it does when one step would build more than 16 successors for each configuration it may keep, even if most of them merge, since each is built before it can be merged. Spike trains and port readings are kept in the merge key, so only computations with the same record so far are merged and each distinct train is reported once; a deterministic network costs one configuration a step. It refuses jitter (see Timing robustness).
  • GPU (Metal), for large networks is listed on Macs. It samples like the CPU engines, but runs every run of every network in the population at once on the GPU, with one threadgroup per run. On an M5 Pro it is about 6 to 12 times faster than all CPU cores for networks of a thousand neurons or more. Its rule choices come from a hash of the seed rather than System.Random, so individual runs differ from the CPU engines' while following the same distribution, and the same seed repeats them. Batches too small to repay the GPU round trip, and networks with more than 64 output neurons, delays above 65,535 or emissions above 32,767, run on the CPU instead.

Algorithms

  • Generational, rule expressions only (roulette wheel or tournament): the original algorithm. It changes only the rule expressions and keeps the starting network's structure.
  • Generational, structural: tournament selection, a crossover that works across different topologies, and structural mutation.
  • (mu + lambda) evolution strategy: mutated children compete with their parents, and the best survive. Children win ties, so the search can drift across equally fit networks.
  • MAP-Elites: keeps the best network for every size, which maps out how fitness trades against size.
  • NEAT-style speciated: groups similar structures into species that share fitness, so new structures get time to improve.

Structural mutation (Search/Operators/StructuralMutations.cs) is a weighted mix of small edits. It can:

  • nudge or replace an expression;
  • change a delay, a consumed or produced count, or whether a rule fires;
  • add or remove rules, synapses and neurons;
  • split a synapse with a relay neuron;
  • nudge initial spikes.

Every algorithm ranks equally fit networks smaller first.

Checks, lexicase selection and modules

Every task also scores a network on each separate thing it checks: each gap of a sequence, each step of a binary word, each example of a function or acceptor, each number of a set. Two options in Settings > Search use these checks, and both are off by default:

  • Lexicase parents (--lexicase on): parents are picked by lexicase selection. Each pick goes through the checks in a random order and keeps only the networks that do best on each. A network that is the only one to get some part of the target right gets to breed even when its fitness is low.

  • Build from modules (--modules on): the run keeps a library of parts (modules) and builds networks out of them. Modules come from three places, all found by the run itself:

    • a change that made a child get a check right that its parent did not, cut out with the neurons around it;
    • the network that solved each stage of an iterative run;
    • side runs. When the search stalls, the run finds the first check no network does yet, such as gap 7 of a sequence. It evolves a small network (at most 24 neurons) for just the gaps around it on the side. With Triggered modules on (--triggered off to stop it), every other side run instead builds a part that waits for a spike on an input before it makes the missing gaps, so it can follow on from what a host already does rather than run beside it from the first step. A part that a side run solved is reused the next time the same gap is missing, rather than evolved again.

    Copies of the new module go into the best networks. With Module incubation above 0 (--incubate N, 30 generations by default) those networks first evolve on their own, seeded only with each other, until one beats the main run or the generations run out; the best of them then join the main run, and the module is credited with whether it led to a better network. When a module takes over as the output, the old output neuron sends to it, so what the host already made can still pass through. Side runs and incubation cost evaluations the main run's generation count does not show; -modules.txt gives how many generations ran on the side.

    Mutation can insert a copy of a module, picked by how often it has helped, or free one so its neurons evolve like any other. Inserted modules are frozen unless Freeze modules (--freeze off) is off: other edits cannot change their rules or inner synapses, only their initial spikes and how they are wired in. --module-files a.json,b.json (Module files in the menu) starts the library with saved networks, such as the best network of an earlier run.

    The run saves the library, with how often each module was tried and helped, as -modules.txt next to the network.

For gap sequences that keep growing, such as Fibonacci, the advisor suggests turning both on. On 16 values of Fibonacci (100 neurons, 2000 generations, 15 seeds), runs with both solved 7.3 values on average. Runs without them solved 5.9, or 6.5 when given as many extra generations as the side runs added on average.

Compile, then shrink

compile builds a network that is correct by construction instead of evolving one from scratch, then evolves it smaller while it stays correct:

snp-evolution compile --target "1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987" --shrink 2000
snp-evolution compile --kind set --target "2,3" --generations 400
snp-evolution compile --kind set --target "2,4,6" --program my-program.txt
  • Sequences are fitted with a recurrence: a few starting values, then each value is a fixed sum of the ones before it, such as g(n) = g(n-1) + g(n-2) for Fibonacci. Up to three values back are tried, each counted up to four times. The network holds each value as spikes in pairs of neurons. Each gap is made by draining, one spike per step, the pairs that hold the values it is the sum of, while the drained spikes are written into fresh pairs for the gaps that come later. Gaps come out exact and never stop, so the network keeps going past the values given. Fibonacci compiles to 15 neurons and is right for every value checked, not only the 16 given. Shrinking uses the same search as evolve-parts, MAP-Elites over hardware cost: for 2000 generations it reached 13 neurons on 2 of 5 seeds and left the other 3 at 15. The (mu + lambda) shrink it replaced reached 10 neurons on 4 of 5 seeds. After shrinking, the run checks the next 4 values of the recurrence, since shrinking only sees the values given; every shrunk Fibonacci network so far still got them right. A sequence that follows no recurrence of that kind cannot be compiled.

  • Sets are generated by a register program, as in the SN P universality proofs: ADD, SUB and HALT instructions, where register 0 is the output and can only be added to. The program is compiled with the standard ADD and SUB modules (Ionescu, Păun & Yokomori 2006) using standard rules, and it outputs the interval between two spikes. Programs are evolved unless --program FILE gives one, one instruction per line:

    0: ADD r1 -> 1          # add one to r1, go to 1
    1: ADD r0 -> 2 | 3      # add one to r0, go to 2 or 3 at random
    2: SUB r1 -> 1 else 3   # take one from r1 and go to 1, or go to 3 if r1 is zero
    3: HALT                 # generates the value of r0
    

    The program search scores a program by following every choice, which is far cheaper than simulating its network. It learns the set a few numbers at a time, and its last stage looks beyond the largest number, so programs that overshoot are caught. Parents are picked by lexicase selection over the target numbers plus a "nothing extra" check (--lexicase off to pick the fittest instead). Without it, the search usually gets stuck on a program that generates too much, such as every even number. Over 10 seeds of 3000 generations each:

    Target Lexicase Fittest only
    {2,4,6} 10 found 3 found
    {1,2,3,5,8,13} 2 found 0 found
    Fibonacci up to 987 0 found; 9 seeds got as far as 34 0 found; every seed stuck before 5

    The programs it finds for larger sets fill all 16 instructions and list the numbers rather than compute them, so for sets such as Fibonacci's give the program yourself.

  • Shrinking (--shrink N generations, 300 by default, 0 to skip) starts every network from the compiled one. Children compete on fitness first and size second, and a smaller network only counts once it passes the same retests that stop a run. It uses the usual edits apart from those that only add, plus two more: bypassing a neuron (whatever sent to it sends on to its targets) and merging two neurons with the same rules.

The run saves Program.txt (the recurrence or program), Compiled.* and Shrunk.* (network, notation, graph and page) and Shrunk.csv (fitness history).

Evolving library parts

evolve-parts evolves small verified parts that later runs can build machines from. Each part has a start input, a done output and typed data ports, and a contract it must meet: nothing comes out before start, done fires exactly once with the right values on the outputs, every neuron ends where it began, and done fires in time. The goals are ten general arithmetic parts and four control parts, none specific to any target (FirstParts.Table() prints them):

Part Ports besides start and done Contract
Delay k none done fires k steps after start (k = 1..4, one contract each)
Fan-out count in; count out x2 both outputs carry n
Increment count in; count out output carries n + 1
Double count in; count out output carries 2n
Add count in x2; count out output carries n1 + n2 (cases cover pairs up to 6 + 6)
Interval to count interval in; count out output carries n
Count to interval (timer) count in; interval out two output spikes n steps apart
Register count in; count out holds n until started again, then drains it
Zero test count in; done-zero, done-nonzero the right branch fires, the other never
Sequencer done out xk fires its outputs in order, each one step after the previous (k = 2, 3)
Join trigger in x2 done fires once after both inputs, in either order and up to 4 steps apart
Fork trigger out x2 both outputs fire on one step, then done
Merge trigger in x2 done fires once after whichever input fired (never both)
Select trigger in; done-one, done-zero done-one when the input fired with start, done-zero when it did not

Count cases run from 0 to 8 plus 12, to catch a part that only memorised small values. A count part may take 12 steps per unit of its largest value plus 20, which admits parts compiled from register programs (see below). A trigger in-port's value is the step it fires on, counted from 1 with start, and 0 means it does not fire. Start opens the round of a control part, and done may not fire before the last trigger input has arrived. Join's cases cover both orders, equal steps and the largest gap. A control part may take 3 steps per step of its last input plus 4.

snp-evolution evolve-parts --seed 1
snp-evolution evolve-parts --only "add,fan-out" --budget 200000 --redo on

For each contract the library has no part for, the command evolves one from scratch with MAP-Elites (lexicase parents, standard rules, deterministic neurons), verifies it on the exhaustive engine over every computation of every case, then shrinks it with MAP-Elites over hardware-cost cells and keeps the cheapest network that still verifies. Hardware cost orders by neurons, then synapses, then rules, then register width (the most spikes any neuron holds during the cases); distinct rules and lasso table size break any remaining tie. A deterministic neuron never has two rules that apply to the same spike count: a contract must hold on every computation, and a network of such neurons has one per input, so the exhaustive engine follows one configuration a step instead of every branch. New neurons and added rules are drawn to leave their neuron deterministic, and an edit or crossover that would give a neuron rival rules is made again, keeping the parent after eight tries, since dropping a rule there cost sequencer 3 half its solves. The search logs its best fitness at each quarter of its budget.

A network is scored on five rules per case: quiet before start, done once (exactly one done, the right one), right values, back to start and on time. The values are read up to the right done, or the first done, or the end of the run when no done fires, so a network that computes the right values but misfires done still scores for them. For a part with data out-ports the values are half of each case's score and the other four rules the other half, since a network that ignores its input meets all four by firing done on time and keeping quiet; with every rule weighted alike that scored about 0.8, above most networks that compute anything. Lexicase still picks parents on each rule separately. A part without data out-ports, such as a delay or a zero test, scores the four rules alike.

The search learns a contract a few cases at a time: first on the three cases with the smallest inputs, then twice as many each time a stage is solved, ending with every case (a stage holding more than three quarters of the cases is skipped). The archive is scored again at each stage, so its stepping stones carry over, and only networks on the last stage are verified. A contract with one case has one stage. --staged off scores every case from the start.

  • --seed N (1 by default): each contract gets its own seed from it, so the same seed writes the same library whichever contracts run together.
  • --budget N (50000 by default): search evaluations per contract; shrinking spends a quarter as many again.
  • --only NAMES: contracts whose names contain any of the comma-separated names.
  • --library DIR (parts/ at the repository root by default): the library folder.
  • --engine exact|sampled: the engine the search scores on; verification is always exhaustive.
  • --redo on: evolve contracts the library already has a part for; the new part replaces the old only if it is cheaper.
  • --robust J: shrink towards the most robust part at jitter J rather than the smallest (see Timing robustness).
  • --staged on|off (on by default): learn the contract a few cases at a time, smallest inputs first.
  • --route both|search|compile (both by default): how a part with count ports is found (see Compiling count parts). Parts without count ports are always searched for.
  • --only also takes the building blocks (add 2, decrement, gate, add loop). A name that is a contract's own picks only that contract, so add does not also pick add 2.

The library folder holds one JSON file per contract (delay-1.json, zero-test.json) with the network (in the usual network file format), contract, port binding, hardware cost, latency, seed, run and evaluations. Loading verifies every part again and refuses a file whose part fails its contract or whose contract differs from the catalogue, naming the file. Two parts that read the same on every case of a contract count as one, and the cheaper is kept under the first one's id.

parts shows what the library holds: a table of the kept parts (route, cost, latency, proof, seed and evaluations) and the first-part contracts with no part yet. The route says whether a part was searched for, compiled from a register program or composed from other parts. --only NAMES or --part FILE shows each part in full, with its ports, what it reads on each case, the program a compiled part came from and its network.

snp-evolution parts
snp-evolution parts --only sequencer

The run ends with a table of each contract, whether it was solved, the evaluations it used, and the kept part's neurons, synapses, latency and robustness to jitter. It exits with 2 when a contract is left without a part.

With seed 1 and the default budget (47 minutes on a 15-core machine; about 70 seconds since part search keeps its neurons deterministic, when it also solves delay 1), the run solved delay 2, 3 and 4 (2 neurons, 1 synapse each) and sequencer 2 (6 neurons, 8 synapses). Delay 1 was solved by other seeds but not this one, and sequencer 3 and the zero test came close (best fitness 0.97). None of the parts with count ports was solved. Their best networks score about 0.8, getting every rule right except putting the right values out and firing done once. A part that holds a count until start needs the parity trick of the hand-built register: each input spike is stored as two, and start makes the total odd so that a rule matching odd counts drains it. Larger networks, lexicase off, and partial credit for right values when done misfires all left the best near 0.8 within 50000 evaluations. Weighting the values as half of each case and learning a few cases at a time solved none of them over 10 seeds either, but moved where the search stalls: increment and register get the right values on their first three cases and leave one neuron holding a spike, add and register get all but one case right, the zero test never fires done for n = 0, and fan-out and double each reached all their cases on one seed. The parts/ folder in the repository holds the parts this run found.

Compiling count parts

Search alone has not found a count part, so for a contract whose data ports are all counts, evolve-parts also has a compile route (--route compile, or both, the default, which falls back to search when compiling finds nothing):

  1. Program search. It searches for a function program: a register program whose inputs start in r0, r1, …, whose outputs are the registers after them, and whose HALTs each name a done port. The interpreter scores it on the contract's cases with no network built. Each case is checked for the right done, each output, every other register back at zero, being on time, and halting, with lexicase parents. Function programs never make ADD choices, and the search stops after 2000 generations.
  2. Taking instructions out. Instructions are removed one at a time while the program still passes.
  3. Compiling. The program is compiled with the textbook ADD and SUB modules (Ionescu, Păun and Yokomori 2006). Each count in-port relays every spike into its register as two, and before each HALT a SUB loop drains each output register onto its out-port, one spike per unit. The interpreter knows the compiled network's timing (1 step to start, 1 per ADD, 3 per SUB above zero and 4 at zero, 3 per unit drained plus 4, 1 to done), so programs that would be late are scored down without being built.
  4. Checking past the cases. The compiled part is checked up to twice its largest case. A counterexample joins the program search's cases, and the search runs again from the last program, up to four rounds in all.
  5. Shrinking. The part is verified and shrunk with MAP-Elites as a searched part is, with the whole budget, since program search spends no network evaluations.

The part file keeps the program and the size the compiler made (parts shows both). Under --profile hardware parts are always searched for, since the modules use rules outside the profile.

Over 10 seeds every count first part and every building block but the add loop had a program, and each compiled part verified and was admitted. The add loop was admitted on 2 of 10 seeds, after counterexamples. The compiled parts are 3 to 7 times the size of the hand-built ones (register 21 neurons after shrinking, against 5), because each instruction costs about four neurons. They take 7 to 11 steps per unit, which is why count contracts allow 12 per unit. RESEARCH.md "Parts without hand-building" has the table.

snp-evolution evolve-parts --only "register,add,zero test,fan-out,increment" --route compile

Composing machines from parts

Composition search (the "Composition search" algorithms) builds each network from copies of library parts, glue neurons and the synapses between them, and never changes a part's inside. Parts are wired port to port by type. For a contract task the task's own ports count as typed ports too: a part's count in-port can be fed straight from the task's count input, and its done port can drive the task's done neuron. The glue then starts as quiet relays with no synapses of their own, since the parts and the ports give the structure. Without a contract, glue starts random, because something has to fire.

snp-evolution compose --task "Contract multiply" --hand-built on --library parts-hand-built
snp-evolution benchmark --task "Contract multiply" --algorithm Composition --lexicase on --engine sampled --hand-built on

compose runs composition search for one suite task, with lexicase parents, 30000 evaluations and tournament selection unless told otherwise (--algorithm, --evaluations, --generations, --population, --seed, --max-parts, --glue). It exits with 2 when the task is not solved.

Promotion. When a composition solves a contract, it is promoted to a part whose contract is the target's (Promotion). The run scored it on the task, which may test less than the contract, so it is first verified on the exhaustive engine. A contract counts as solved only at fitness 1, since a neuron left holding a spike means the part cannot be started again. The promoted part's file holds a recipe rather than a network: its children by contract name, its glue, and its wires, with ports named ("2.sum") so a cheaper child that later replaces one is wired the same way. Loading builds promoted parts after the parts they are built from and verifies them again. A promoted part's size is its children's, so the 24-neuron cap applies only to modules evolved or harvested as one network.

Proposals. When a composition run stalls, it asks for the parts it lacks (Proposals/). For a sequence target it first fits the shape of the target, once: a small linear recurrence asks for one register per term, an add per sum and a double or fan-out per coefficient above one, and gaps with a constant first or second difference ask for registers and adds. Fibonacci gaps give two registers and an add, and 2^k gives a register and a double. When nothing fits exactly it proposes nothing, since a wrong part costs a whole part evolution. Then it reads the checks nobody passes: a missing gap becomes a delay contract, and a contract's failing cases become a sub-contract. Each proposal is evolved and verified like a first part (--proposal-budget N, 20000 evaluations by default), and a solved one joins the library, with copies put into the best networks. --propose off turns this off. The run log and -parts.txt list every proposal and its outcome, and the evaluations count as "proposed parts".

Reuse. Every composition run saves -parts.txt: each library part's copies in the best network, with copies nested inside promoted parts in brackets, and its mean copies per network in the final population. It ends by saying whether the best network reuses a promoted part. Counting reads the module tags of the flattened network, so it works for any algorithm. Benchmarks of composition search add a column of the parts the best networks hold, as runs out of all. This is not the module library's uses and wins, which count whether inserting a copy made a child fitter during the search.

Hand-built parts. Before the compile route, evolve-parts had not found a part with count ports, so --hand-built on (Composition: start from hand-built parts in the menu) adds hand-built ones: register, add, increment, fan-out, zero test, decrement and a gate that passes a count on or swallows it. It also adds an add loop, a + n x b, built from seven of them and ten glue neurons and promoted like any composition (49 neurons, latency 223). Its done waits for both the loop's last round and the accumulator's own done, since a large a is still draining into the sum when the loop ends. Every hand-built part is verified on its contract. They are off by default, since the library should be one the runs found, and a run with them never saves to the default parts/ folder. --hand-built leaves gives the parts without the add loop, as a control.

On n1 x n2 in count encoding (10 seeds per algorithm, 6000 evaluations each, lexicase parents, 5 sampled runs per network, the parts/ library plus the hand-built parts), composition search solved 10 of 20 runs with the add loop in the library: 7 of 10 with MAP-Elites (median 3205 evaluations) and 3 of 10 with tournament selection (median 4325). The add loop was in the best network of 18 of 20 runs. With --hand-built leaves, the same parts without the add loop, no run solved it (best fitness 0.875 on average; Fisher's exact test p = 4e-4). Before the add loop's done was joined to its accumulator's (see Proving parts past their cases), the same runs solved 14 of 20, 7 of 10 for each algorithm. The fix cost tournament selection 4 runs (7 against 3 of 10, p = 0.18). A network that only relays start to done scores 0.85 on multiplication, because every case with a zero product passes, so without a loop part the search stalls there. The promoted multiplier (compose --task "Contract multiply" --hand-built on --seed 1) has 56 neurons and 68 synapses and takes 261 steps for 6 x 5.

snp-evolution benchmark --task "Contract multiply" --algorithm Composition --seeds 10 --budget 6000 --lexicase on --repetitions 5 --engine sampled --hand-built on
snp-evolution benchmark --task "Contract multiply" --algorithm Composition --seeds 10 --budget 6000 --lexicase on --repetitions 5 --engine sampled --hand-built leaves

From compiled parts only. Since the compile route (see Evolving library parts), parts/ holds count parts and an add loop compiled from register programs, with no hand-built input. The counts below are under the looser latencies the compiled parts need. With --hand-built off and that library, the same benchmark solved 13 of 20 runs:

  • 7 of 10 with MAP-Elites (median 2645 evaluations);
  • 6 of 10 with tournament selection (median 2610).

The compiled add loop was in the best network of 17 of the 20 runs. The control, --hand-built leaves over the timing and control parts, solved none (Fisher's exact test p = 1.3e-5), and neither did the compiled library without its add loop. The multiplier promoted from it (compose --task "Contract multiply" --hand-built off --seed 1) has 56 neurons and 82 synapses, and takes 501 steps for 6 x 5.

snp-evolution benchmark --task "Contract multiply" --algorithm Composition --seeds 10 --budget 6000 --lexicase on --repetitions 5 --engine sampled --hand-built off

Proving parts past their cases

A contract's cases test n up to 8 and one larger value, and composition trusts every part completely. verify proves a part meets its contract for every input up to a bound N, raising N until a time limit, and records the bound in the part's file:

snp-evolution verify --library parts
snp-evolution verify --part parts/delay-2.json --seconds 60 --bound 12
snp-evolution verify --library DIR --only "add loop"

Each contract the code knows is built from a specification (Specifications) and the values its cases test. A specification says which inputs it covers (an interval is at least 1, a divisor at least 1, a subtraction needs a >= b), the outputs and done branch any input should give, and the latency any input is allowed. Latencies grow with the values, 3n + 4 for most count parts and a round of 4n + 40 steps per unit for loops, never below 400 for a loop. A contract's latency is the most any of its cases is allowed, and bound N allows the most any input at N is allowed, never less than the contract's. A contract finds its specification by everything it says rather than its name, so a contract that only borrows a known name is not proven against the wrong function. A contract with no data in-ports is its own specification. Proposed contracts have none and are recorded as not proven.

Bound N runs every input whose largest value is N through the Verifier, which runs a network on a contract's cases on the exhaustive engine and gives a verdict: passed, failed with a counterexample, or unknown with the reason. Part search, promotion and the part library check parts through it too. A case too wide to follow exactly ends the check, since sampling is not proof. A failing check stops it with a counterexample: the input, the rule broken, what was expected and read, and, for a deterministic part, a step-by-step table of the spikes each neuron held (by port name) and when it fired. The file gets "Proven": { "UpTo": N, "AllInputs": ..., "Stopped": ..., "FailsAt": ... }. AllInputs is true when the bound covers every input there is, as for a part with no data in-ports or only binary ones. The command exits with 2 when any part has a counterexample, and a library holding a part with a recorded counterexample no longer loads.

The check also runs before a part enters the library: from evolve-parts, from promotion and from proposals, up to twice the largest value its cases test, or for at most 10 seconds (BoundedCheck.Admit). A part with a counterexample there is refused and logged. Every part in parts/ and parts-profile/ is proven for every input, as none has data in-ports. In 20 seconds each, the hand-built register reaches N = 1662, add 193, gate 801 and zero test 2181. The check found that the hand-built add loop failed at a = 14, b = 0, n = 0, since done fired a step before the accumulator had finished draining a into the sum, and its cases only go up to a = 2. With done joined to the accumulator's done, it is proven up to 30 in two minutes.

Exporting to hardware

Evolved networks can be written out for FPGAs (Verilog) and neuromorphic toolchains (NIR), and every export is checked by running it next to our own engine and requiring the same spikes on every step of every neuron.

Hardware profile. --profile hardware (for evolve, compose and evolve-parts; Settings > Simulation > Hardware profile in the menu) limits rules to the form integrate-and-fire hardware runs: a^{>=k} / a^* -> a, which fires once the neuron holds at least k spikes, consumes them all and sends one, or the same rule forgetting. Each neuron has one such rule and starts empty, since NIR has no initial state, and a delay is axonal. A profile network is an integrate-and-fire network with integer threshold k, reset to zero and unit weights. Generation makes profile rules directly, and every edit and crossover puts its child back within the profile (HardwareProfile.Conform). HardwareProfile.Problems says whether any network fits and names each rule that does not. The profile is off by default. Over 5 seeds it solved the delay and sequencer contracts as often as unrestricted runs (24 of 30 runs against 23), and, like them, none with count ports; see RESEARCH.md. parts-profile/ holds the parts evolve-parts --seed 1 --profile hardware --library parts-profile found.

Verilog. export-verilog writes any deterministic network as a synthesisable Verilog module, a testbench and the trace our engine expects:

snp-evolution export-verilog --part parts/delay-2.json --out export
snp-evolution export-verilog --network saved.json --steps 60 --out export

Each neuron is its own module: a spike counter, its rule conditions as lasso lookups (a tail table, then a period), and only the state its rules need. That is a countdown for a delayed legacy rule, a countdown, closed flag and pending spikes for a delayed standard rule, and a shift register of spikes in flight for axonal delays. One clock is one step. Input neurons and a part's ports are module ports; a part's testbench drives every contract case. Counters are as wide as the most spikes any neuron held on the contract's cases (HardwareCost register width), and an overflow output goes high if a run ever needs more. A network that could choose between two rules is refused, with the rules and the count they share, since hardware would need a policy for the choice. When Icarus Verilog is installed, the command compiles and runs the testbench and compares its output with our engine's; it exits with 3 if they differ. Install it with:

brew install icarus-verilog

NIR. export-nir writes a profile network as NAME.nir.json, with every contract case and the trace to match. tools/snp_nir.py turns that into a NIR file (write) and runs the NIR file in norse on every case (check); the command does both when the Python side is set up:

snp-evolution export-nir --part parts-profile/delay-2.json --out export
python3 -m venv tools/.venv && tools/.venv/bin/pip install -r tools/requirements.txt

A network outside the profile is refused, naming the rule that breaks it, because only there is the mapping exact. Each neuron becomes an IF neuron with threshold k - 0.5 and reset 0, synapses have weight 1 (0 from a forgetting neuron), and an axonal delay is a Delay node. One SN P step is one time step; the full mapping is at the top of tools/snp_nir.py and in RESEARCH.md. snnTorch is not used, because its NIR import reads IF as a leaky neuron.

The co-simulation tests skip with the reason when iverilog or the Python packages are missing.

Uppaal. export-uppaal --part FILE writes a part as Uppaal timed automata (NAME.xml) with queries for its contract (NAME.q), and runs them with verifyta when it is on PATH, at UPPAAL_VERIFYTA, or in an Uppaal folder in /Applications. It exits with 3 when a query fails. This is our own translation of our engine's step, as the published one could not be read (see RESEARCH.md). It has not yet been run under Uppaal, so treat it as untested.

Timing robustness

Asynchronous hardware jitters, so a part that works only in lockstep will break there. SimulationOptions.Jitter = j makes whatever a neuron sends along each synapse arrive 0 to j steps late, drawn at random per synapse per step; a late arrival is lost if its receiver is closed when it lands, and input from the environment is never late. With j = 0 nothing is drawn, so every run is as before. Only the sampling engines run it. Each engine declares what it can run (EngineSupport): the exhaustive engine cannot follow jitter, since a delay choice for every spike would multiply the computations, and the GPU kernel has no jitter, axonal delay or Ports readout. Given a trial it cannot run, an engine returns an unsupported result rather than falling back; RoutedEngine sends each trial to an engine that supports it, which is how the GPU engine passes such trials, and small batches, to the CPU.

A part's robustness at jitter j (Robustness) is the share of 100 sampled runs, each running every case once, in which every case still stays quiet before start, fires the right done once with the right outputs, and returns every neuron to its starting spikes. The latency bound is left out, since jitter moves timing by design; time-free SN P systems ask the same of a result. Runs get 1 + j times the lockstep steps, and spikes still on their way when a run stops count against returning to start.

The evolve-parts summary reports every library part's robustness at j = 1 and j = 2. --robust J makes robustness a MAP-Elites dimension of the shrink: cells are robustness at jitter J in tenths by neuron count, and the shrink keeps the most robust part that verifies, the cheapest among equally robust ones.

Benchmarking and choosing an algorithm

Benchmark > Every algorithm runs every algorithm on every suite task over several seeds. Each run has a budget of network evaluations, so the results compare fairly across machines and engines. Every evaluation a run makes is charged to its budget by kind: networks scored on the task's cases (retests included), which is what the budget limits, and beside them exhaustive checks on a contract, bounds of a bounded proof, register program runs and runs under timing jitter, which a run's evaluations line lists when there are any. For each algorithm and task it reports how many runs solved the task, the median evaluations to solve, the mean best fitness and the mean size of the solutions. It saves the results as benchmark.txt and benchmark.csv.

Benchmark > Find the best algorithm picks the best algorithm for the selected task by successive halving. Every algorithm gets a small budget, the better half goes on with double the budget, and this repeats until one is left. You can then make the winner your algorithm.

Both also run without the menu:

dotnet run --project Cli -c Release -- benchmark --budget 3000 --seeds 3 --task "Compute" --algorithm "MAP"
dotnet run --project Cli -c Release -- select --task "Accept even"
dotnet run --project Cli -c Release -- evolve --target "1,1,2,3,5,8,13" --generations 4000 --population 100
dotnet run --project Cli -c Release -- evolve --kind binary --target 001001001001
dotnet run --project Cli -c Release -- tasks        # list the suite
dotnet run --project Cli -c Release -- algorithms   # list the searches

--task and --algorithm match any name containing the text. These runs use the exhaustive engine unless given --engine sampled. --lexicase on picks parents by lexicase selection, and --repetitions N sets the sampled runs per network. A task or algorithm named exactly wins over those that only contain the name. Composition search builds from --library DIR, plus the hand-built parts with --hand-built on; a contract it solves is promoted into that run's library.

evolve builds a network from scratch for a target. --kind is set, sequence (the default) or binary. evolve uses the default algorithm, MAP-Elites, unless given --algorithm, and --seed N makes a run repeatable. It saves the result like the menu does, and exits with status 2 if no network solved the target.

reach compares setups on one target with the same evaluation budget: flat MAP-Elites, MAP-Elites with the modular loop and lexicase parents, and composition search from the part library, whose recorded cost is taken off composition search's budget unless given --charge-parts off. Each setup runs on seeds 1 to N in parallel, one CPU thread each. The report gives each setup's reach, which is how many of the target's checks its best network gets right, in order, before the first it misses, with Mann-Whitney tests between setups. It is saved as reach.md and reach.csv:

snp-evolution reach --target "1,1,2,3,5,8,13,21,34" --evaluations 20000 --seeds 10 --setups flat,composition

The solution folder SNP_Evolution/ holds one project per layer. They are listed lowest first, and each may reference only the projects listed before it. A project's namespace is SnpEvolution. followed by its folder name, and the tests in SNP_Evolution.Tests/Layering fail if a project references one listed after it or a namespace depends on itself.

  • Model/: the SN P system.
    • Rule, Neuron and Network describe a system.
    • SpikeCondition compiles a rule expression into the exact set of spike counts it matches.
    • NetworkNotation renders a network as a readable text table.
    • HardwareProfile says which rule forms and limits a chip takes.
    • ReferenceNetworks holds the hand-built natural and even numbers systems.
  • Simulation/: runs networks.
    • CompiledNetwork flattens a network into arrays.
    • NetworkStep steps one computation, with the rules chosen by its caller; NetworkSimulation chooses them at random.
    • InputSpikes describes what the environment feeds the input neurons.
    • NetworkRunner samples runs.
    • The ISimulationEngine implementations run whole batches of trials, and declare what they support; Metal/ holds the GPU engine, whose kernel is embedded in this project.
  • Specs/: what a network should do, and the checks that it does.
    • Accounting/ holds the EvaluationBudget every evaluation is charged to, what a search spent and why it stopped.
    • Contracts/ holds contracts, their specifications and the catalogue built from them (the first parts and the arithmetic contracts).
    • Tasks/ holds the tasks, their scoring and when a score counts as solved (Solved).
    • Parts/ holds the part records, hand-built parts, compositions and recipes, and the module library.
    • Verification/ holds the Verifier, the bounded check and the robustness measure.
  • Compilation/ compiles recurrences and register machines into networks.
  • Search/: every search, behind ISearch and listed in SearchCatalog, with the run setup (EvolutionContext), composition search, part evolution, program search and the shrink.
    • Genome/ makes random networks (NetworkFactory, GenomeSpace); Fitness/ scores populations (FitnessEvaluator).
    • Operators/ holds the swappable mutation, crossover and selection operators; Algorithms/ the genetic algorithms and the shared generation loop.
    • Modules/ holds the modular loop: inserting and freezing modules, harvesting the changes that pay off, side runs on what is missing, promotion and the hand-built add loop.
    • Proposals/ proposes parts when a composition run stalls, from its failing checks or the shape of its target.
    • Benchmarking/ holds the benchmark harness, the task suite and the algorithm selector.
  • Storage/ saves and loads networks, parts and contracts as JSON, through one set of settings (Json), and fitness history as CSV.
  • Export/ writes Verilog, NIR and Uppaal models of networks and parts, and runs the external tools that check them.
  • Application/ holds the settings and one run service per flow (evolve, compose, compile, evolve-parts, reach, benchmark, verify, export), which the menu, the command line and the tests all call. A service takes settings and a request, reports progress through a callback and returns a typed result; it never writes to the console. Catalog lists the engines, fitness functions, tasks and algorithms the settings offer, PartLibraries loads part libraries and part files, and EvolutionSession builds and runs one evolution.
  • Cli/ is the snp-evolution tool. Program.cs starts the console menu, or runs a command when given arguments. It holds the console menus, one per submenu, and the command line: Commands/ has one class per command, listed in CommandRegistry, and SettingOptions declares each setting that is both a flag and a settings menu row once, for both.

SNP_Evolution.Tests/ mirrors these folders. The builders and setup more than one test needs (test networks, parts, temporary folders, console capture, engine theory data) are in Fixtures/, and Golden/ holds the golden runs.

How spikes are stored

Spikes are stored as counts (up to long.MaxValue) rather than strings of a. Each rule expression is a regex over a, and the counts it matches always settle into a repeating pattern. Each rule is therefore compiled once into a short lookup table plus a repeating cycle. Matching a rule costs the same whether a neuron holds 2 spikes or 2 trillion. The supported syntax is literals, ., groups, | and the ?, *, +, {n}, {n,} and {n,m} quantifiers. Saved networks write SpikeCount as a number; older files that wrote it as "aa" still load.

When an evolution finishes, the best network is printed and saved as a .txt table next to its .json, with spike counts written back out as runs of a (a^N for long runs).

Extending

Each kind of extension is an interface to implement in the project it belongs to, plus one line where it is registered, after which the menus, the command line, the benchmark and the tests pick it up:

  • Simulation engine: implement ISimulationEngine in Simulation/ and add it to Catalog.Engines in Application/Catalog.cs. An engine receives a whole batch of trials (a network, its input and what to read back) so it can spread the runs out, and declares what it can run in Support. Read each network through CompiledNetwork.Of(network), whose flat arrays are ready to copy to a GPU, and seed any per-run generators from the Random passed in, as ParallelCpuEngine does.
  • Search: implement ISearch<TCandidate> in Search/ and add it to SearchCatalog.All in Search/SearchCatalog.cs (or call SearchCatalog.Register at run time). A search takes a SearchRequest (the task, the EvaluationBudget every evaluation is charged to, the run's random source and log, and optionally seeds, a stall policy and how to make and score networks) and returns a SearchOutcome (why it stopped, the best candidate, what it spent). A genetic algorithm only needs to implement IGeneticAlgorithm, built from the EvolutionContext, and be registered as an EvolutionSearch; it then runs in the shared generation loop with stall recovery and solve confirmation. Or combine new IParentSelection, ICrossover or IMutation operators in Search/Operators/ with an existing algorithm. A network search that starts from nothing is automatically included in the benchmark and the selector; one that improves the networks it is given, such as the shrink, says NeedsSeeds.
  • Task: implement ITask in Specs/Tasks/ and add it to TaskSuite in Search/Benchmarking/TaskSuite.cs for the benchmark, or to Catalog.Tasks in Application/Catalog.cs for the settings menu. A task lists its cases (input spikes and readout), then scores and describes the results. A part's behaviour is better written as a Specification in Specs/Contracts/, which the contract task, the verifier and the exporters all read.
  • Fitness function: implement IFitnessFunction in Specs/Tasks/ and add it to Catalog.FitnessFunctions in Application/Catalog.cs. It scores a generator's sorted outputs from 0 to 1.
  • Exporter: exporters share no interface, since each target takes different inputs. Write the exporter in Export/ as a static class that turns a network or a part into its files, add a method to ExportService in Application/ that writes them and runs the target's own check, and give it a command as below, deriving from ExportCommand in Cli/Commands/ExportCommands.cs.
  • Command: derive from Command in Cli/Commands/ and add it to CommandRegistry.All. The command declares its name, a one-line summary and its options; usage, option parsing and errors come from the declarations, and a test checks the command table above against the registry. An option several commands take is declared once in CommonOptions, or in SettingOptions when it is also a settings menu row. The command should call a run service in Application/ rather than run the flow itself.

New code goes in the lowest project that has everything it needs, and a project may only reference projects below it, so a check never depends on a search and a search never on the command line.

About

Final Year Project, Evolving Spiking Neural P Systems with Genetic Algorithms

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages