Creating A New Mode¶
Note: this guide is in progress and does not contain all helpful details.
Once you have familiarized yourself with Symbulation and its default mode, you might be interested in designing your own experiment, adding functionality to the codebase, and collecting data. This guide explains how you can add a new mode to Symbulation, which is preferred when you have more new functionality that you want to add than can be handled by a couple of configuration settings. The guide assumes that you are familiar with the ideas of inheritance and classes. It gives a fair amount of assistance on how to do those in C++, though familiarity with general syntax of C++ is assumed.
There are a lot of different ways that symbiosis can occur that have overlaps but are functionality different. With Symbulation, we want to both support lots of those different ways of implementing symbiosis while keeping any one approach from getting too cluttered and bogged down with code that isn’t relevant. We also don’t want to repeat a bunch of code that is shared between those different ways (which we don’t always succeed at, but we’re constantly improving the codebase!). We achieve these dual goals with modes.
A mode generally has its own subclasses of Symbiont
and/or Host
and SymWorld
defined so that they can be highly customized to the particular functionality of the mode.
It also has its own compile target and test suite.
The existing modes include:
default
(Host
andSymbiont
in the “classic” Symbulation functionality of interaction or resource behavior values),efficient
(EfficientHost
andEfficientSymbiont
for work on the Dirty Transmission Hypothesis),lysis
(Bacterium
andPhage
to study bacteriophage/bacterial-specific dynamics),and public goods game or
pgg
(PGGHost
andPGGSymbiont
to study ways for symbionts to interact within a host).We’re also working a new mode
sgp
that uses the SignalGP-Lite library to have hosts and symbionts with computer programs as their genomes! You can check out our progress on thecomplex-genomes
branch.
If you want to create new subclasses of Host
, Symbiont
, or SymWorld
, a new mode is the best way to go.
There are several steps to creating your own mode, including following conventions for file structure, adding your own organisms, adding a WorldSetup file, adding targets to the makefile, designing tests, and more.
This guide will walk you through how to properly add most of these features.
Makefile¶
First, you’ll want to add the necessary targets to the Makefile
for your new mode, so that you can compile and test your code as you go.
This file can be a little overwhelming since there is a lot there already, but the bare minimum that you’ll need is a compiling target.
Navigate to the section of the file that looks like this:
default-mode: source/native/symbulation_default.cc
$(CXX_nat) $(CFLAGS_nat) source/native/symbulation_default.cc -o symbulation_default
efficient-mode: source/native/symbulation_efficient.cc
$(CXX_nat) $(CFLAGS_nat) source/native/symbulation_efficient.cc -o symbulation_efficient
lysis-mode: source/native/symbulation_lysis.cc
$(CXX_nat) $(CFLAGS_nat) source/native/symbulation_lysis.cc -o symbulation_lysis
pgg-mode: source/native/symbulation_pgg.cc
$(CXX_nat) $(CFLAGS_nat) source/native/symbulation_pgg.cc -o symbulation_pgg
Choose a one word descriptor for your new mode and add a line following the above template with your mode name.
If you wish, you can also add the more advanced targets that you will find helpful if you need to use Empirical’s debug mode (great for finding memory leaks!) and running the test suite. These each have their own section with the other modes following the format that you can again copy and adapt:
a debug taget, with the naming convention “debug-
” and “ -debug” a testing target, with the naming convention “test-
” a debug while testing target, with the naming convention “test-debug-
”
Primary Folder¶
Then make a folder in SymbulationEmp/source
named <name>_mode
.
Inside of this folder will be a world setup source file (explained in more detail later), as well as any necessary header files such as your new subclasses.
Organisms¶
Next, you will need to add new header files for your new organisms.
The host organism(s) must extend the Host
class in the source/default_mode
, and similarly, the symbiont organism(s) must extend the Symbiont
class.
We’ll go through how to make a new Symbiont
subclass, but the process would be the same for the Host
subclasses.
Specifically, we’ll use the EfficientSymbiont
as an example since it’s a fairly simple class.
Each new class must contain the following (which we’ll go into depth below):
ifndef
macroconstructor(s)
MakeNew()
If you are adding a new trait or component of the genome, you will also need to:
Define the relevant genome/instance variables
Define a custom
Mutate()
Overwrite whichever super class methods your new trait is impacting
You’ll probably also want to add some configuration settings to alter relevant things about your mode at runtime.
Macros¶
Because there is a whole lot of include
-ing going on, you should first put the following into your file to make sure it doesn’t get added more than once:
#ifndef NAMEOFCLASS_H
#define NAMEOFCLASS_H
//All your code will go here
#endif
For example, here is the one from EfficientSymbiont.h
:
#ifndef EFFSYM_H
#define EFFSYM_H
...
#endif
Includes¶
Of course, you’ll be referring to some other files, so you’ll need to include them.
The most obvious will be either Host.h
or Symbiont.h
depending on which you are inheriting from.
Since EfficientSymbiont
also knows about the new world type and host type, here are the includes from it:
#include "../default_mode/Symbiont.h"
#include "EfficientWorld.h"
#include "EfficientHost.h"
Class definition¶
Now it’s time to define your new class! To declare a class that inherits from another class you use the following syntax:
class YOUR_CLASS: public SUPER_CLASS {
//All your class code here
}
For example:
class EfficientSymbiont: public Symbiont {
//All the class code here
}
You can then add whatever new instance variables you want your class to have. Remember that your class will inherit the instance variables from the superclass, so you don’t need to declare any of those.
For example, here are some of EfficientSymbiont
’s new instance variables, with documentation removed:
protected:
double efficiency;
double ht_mut_size = 0.002;
double ht_mut_rate = 0;
double eff_mut_rate = 0;
Constructor(s)¶
You’ll probably want to define a constructor since it’s unlikely the default is what you want.
Your constructor needs to take parameters that the superclass will need as well as any additional parameters that you want.
Here is one way of structuring your constructor for a Symbiont
subclass, which calls the Symbiont
constructor for you:
YourClassName(emp::Ptr<emp::Random> _random, emp::Ptr<SymWorld> _world, emp::Ptr<SymConfigBase> _config, double _intval=0.0, double _points = 0.0) : Symbiont(_random, _world, _config, _intval, _points) {
//Your specific code here
}
Alternatively for a Host
subclass, which needs a few more things:
YourClassName(emp::Ptr<emp::Random> _random, emp::Ptr<SymWorld> _world, emp::Ptr<SymConfigBase> _config,
double _intval =0.0, emp::vector<emp::Ptr<Organism>> _syms = {},
emp::vector<emp::Ptr<Organism>> _repro_syms = {},
std::set<int> _set = std::set<int>(),
double _points = 0.0) :
Host(_random, _world, _config, _intval, _syms, _repro_syms, _set, _points) {
//Your specific code here
}
You may also want to specify copy and move constructors, though they generally aren’t used in Symbulation and can usually just be the default.
MakeNew()¶
Because Symbulation uses a whole lot of inheritance and makes a whole lot of new objects of different subclasses through reproduction events, we rely on every class defining a MakeNew()
method which can be used to make a new organism of the subclass without needing to know the type of the object.
This method should not handle mutations, just make a new organism that is a clone of the current organism, wrapped in an Empirical pointer, and return it.
It seems very simple, and it is, but it has allowed us to reduced a lot of repeated code between modes!
Here is the basic structure:
emp::Ptr<Organism> MakeNew(){
emp::Ptr<YourClassName> baby = emp::NewPtr<YourClassName>(random, my_world, my_config, GetIntVal());
//Setting your specific traits if needed
return baby;
}
Here is an example from EfficientSymbiont
:
emp::Ptr<Organism> MakeNew(){
emp::Ptr<EfficientSymbiont> sym_baby = emp::NewPtr<EfficientSymbiont>(random, my_world, my_config, GetIntVal());
sym_baby->SetInfectionChance(GetInfectionChance());
sym_baby->SetEfficiency(GetEfficiency());
return sym_baby;
}
(Optional) Creating new traits¶
If you are interested in adding a new heritable trait to your organism class, you will need to follow the next couple of optional sections. If you aren’t adding a new heritable trait, you can skip them!
If you want your organism to have a new trait in its genome, you will need to add an instance variable for it up in the protected
section and should probably add a Get
and Set
method as well.
For example, EfficientSymbiont
s have a new heritable trait called efficiency
that is just how efficient they are at actually getting points.
There is an instance variable:
double efficiency;
That instance variable is set in the constructor:
efficiency = _efficient;
And they have a getter and setter for it:
void SetEfficiency(double _in) {
if(_in > 1 || _in < 0) throw "Invalid efficiency chance. Must be between 0 and 1 (inclusive)";
efficiency = _in;
}
double GetEfficiency() {return efficiency;}
Then, it is used in the overwritten version of the AddPoints
method:
void AddPoints(double _in) {points += (_in * efficiency);}
If you add a heritable trait, don’t forget to make a new Mutate
method for it (see below) and include it in the MakeNew
method!
(Optional) Mutate¶
If you are making a new heritable trait, you need to also specify how it should be mutated during reproduction.
You can either completely toss out the Mutate
method of the superclass, as EfficientSymbiont
does because it is testing some different mutation approaches, or you can just add on to the existing functionality.
Since EfficientSymbiont
is a bit complicated in this regard, we’re going to switch examples to Phage
.
The Phage
class has a couple of new heritable traits, but it also wants to keep mutating the original Symbiont
traits without needing to repeat that code.
Here is the general structure for doing that by first calling the superclass method, and then doing your own logic:
void Mutate() {
Symbiont::Mutate(); //or Host::Mutate(); for those subclasses
double local_rate = my_config->MUTATION_RATE();
double local_size = my_config->MUTATION_SIZE();
//Repeat the below for each new trait that needs mutating
if (random->GetDouble(0.0, 1.0) <= local_rate) {
//mutate trait, assuming it should be between 0 and 1
trait += random->GetRandNormal(0.0, local_size);
if(trait < 0) trait = 0;
else if (trait > 1) trait = 1;
}
}
The Phage
class has three new traits and has configuration settings for turning mutation for each of those traits on or off, as you can see in the example:
void Mutate() {
Symbiont::Mutate();
double local_rate = my_config->MUTATION_RATE();
double local_size = my_config->MUTATION_SIZE();
if (random->GetDouble(0.0, 1.0) <= local_rate) {
//mutate chance of lysis/lysogeny, if enabled
if(my_config->MUTATE_LYSIS_CHANCE()){
chance_of_lysis += random->GetRandNormal(0.0, local_size);
if(chance_of_lysis < 0) chance_of_lysis = 0;
else if (chance_of_lysis > 1) chance_of_lysis = 1;
}
if(my_config->MUTATE_INDUCTION_CHANCE()){
induction_chance += random->GetRandNormal(0.0, local_size);
if(induction_chance < 0) induction_chance = 0;
else if (induction_chance > 1) induction_chance = 1;
}
if(my_config->MUTATE_INC_VAL()){
incorporation_val += random->GetRandNormal(0.0, local_size);
if(incorporation_val < 0) incorporation_val = 0;
else if (incorporation_val > 1) incorporation_val = 1;
}
}
}
(Optional) World Class and Data Nodes¶
If you have added new evolvable traits to the organisms, you will probably want to find out information about those traits.
You may also want to change how the environment impacts the organisms, even if you didn’t make new inheritable traits.
In either case, you’ll need to create a new “world” class that inherits from SymWorld
or one of its subclasses.
For example, here is the start of the EfficientWorld
class:
#ifndef EFFWORLD_H
#define EFFWORLD_H
#include "../default_mode/SymWorld.h"
#include "../default_mode/DataNodes.h"
class EfficientWorld : public SymWorld {
//All new code here
}
DataMonitor/Node¶
Empirical provides a powerful data-tracking framework that works with the world classes, so there is only a bit of setup that you need to do to track and output data from your experiment.
We’re going to focus on data collection here, but of course if you want to change how the environment interacts with the organisms, you can do that by overwriting SymWorld
methods in this class as well.
You’ll need to first make a new instance variable for your DataMonitor
. A data monitor is a special version of an Empirical DataNode
that is focused on the data that it most recently received and the distribution of values that it has received previously, so it’s useful for tracking what the data looks like every so many updates.
There is a huge amount of power in the DataNode
functionality that we are ignoring, so if there are different ways that you’d like to track your data, go take a look at Empirical’s documentation!
We want our data instance variables to look like this:
emp::Ptr<emp::DataMonitor<type>> data_node_name;
For example, here is the new data monitor for tracking the efficiency trait of EfficientSymbiont
s:
/**
*
* Purpose: Data node tracking the average efficiency of efficient symbionts.
*
*/
emp::Ptr<emp::DataMonitor<double>> data_node_efficiency;
Before we go further, we should make sure to clean up our memory, so let’s define the world destructor to delete the data node:
~EfficientWorld(){
if (data_node_efficiency) data_node_efficiency.Delete();
}
Next, we need to define what data is going to be stored in the data node. The data nodes use anonymous functions and event triggers so the world knows to add to them whenever specific events happen. The most common event that we want to add data during is Update, so we’ll have a template that looks like this for defining what our data node does:
emp::DataMonitor<type>& GetNAMEDataNode() {
if (!data_node_name) { //if the data node doesn't already exist, make it!
data_node_name.New();
OnUpdate([this](size_t){ //this is the anonymous function that is called every update
data_node_name->Reset(); //assuming you want to clear out data from the previous update
for (size_t i = 0; i< pop.size(); i++) { // go through the population
if (IsOccupied(i)) { //check that there is an organism
data_node_name->AddDatum(pop[i]->GetData()); //add whatever data to the data node
}//close if
}//close for
});
}
return *data_node_efficiency; //hand back the data node that's been created
}
As you can see from the inline comments, we are making a method that makes the data node if it doesn’t already exist. When creating it, we add an unnamed function to the world’s OnUpdate to-do list to go through the population and get the information that we want about each of our organisms, which we then add to the data node.
DataFile¶
To get data out of the data node and into a file, we use Empirical’s DataFile
class.
However, we need a method in the World
class to actually setup the datafile and tell it what it will be doing.
Here is the general structure of that method:
/**
* Input: The address of the string representing the file to be
* created's name
*
* Output: The address of the DataFile that has been created.
*
* Purpose: To set up the file that will be used to track YOUR TRAIT
*/
emp::DataFile & SetupTRAITFile(const std::string & filename) {
auto & file = SetupFile(filename); //A method from the Empirical World class
auto & node = GetNAMEDataNode(); //The method you made previously
file.AddVar(update, "update", "Update"); //You'll usually want the update information
file.AddMean(node, "mean_TRAIT", "Average TRAIT", true);
file.PrintHeaderKeys();
return file;
}
Empirical’s datafiles have many statistical methods already available including all the different flavors of averages, total, min/max, variance, skew, kurtosis, histogram bins, and ways for you to easily add new calculations. You can have multiple datanodes pulled from in the same file if you wish as well.
Finally, you should make a CreateDataFiles
method that can be called in your .cc
to make your new file in addition to the files from the superclass:
/**
* Input: None.
*
* Output: None.
*
* Purpose: To create and set up the data files (excluding for phylogeny) that contain data for the YOUR_TRAIT condition experiment.
*/
void CreateDateFiles(){
std::string file_ending = "_SEED"+std::to_string(my_config->SEED())+".data";
SymWorld::CreateDateFiles();
SetupTRAITFile(my_config->FILE_PATH()+"TRAIT"+my_config->FILE_NAME()+file_ending).SetTimingRepeat(my_config->DATA_INT());
}
You should simply replace TRAIT
with whatever your datafiles are called and add more setup calls if you have multiple datafiles.
(Optional) World Setup¶
If you’ve made new organism(s) and a world, you’ll need a new WorldSetup
file.
The world setup function is responsible for making organisms and placing them into the world.
We are working on refactoring it to reduce duplicated code between modes, but for now, you will need to copy some code that is generally needed by every mode.
The primary difference will be the organism types added to the world, which should now be your newly created host(s) and symbiont(s).
Here is the structure with notes of what you should change:
#ifndef NAMEWORLD_SETUP_C //Change to your mode name thoughout the includes
#define NAMEWORLD_SETUP_C
#include "YOURWorld.h"
#include "../ConfigSetup.h"
#include "YOURSymbiont.h"
#include "YOURHost.h"
//Change to your world type below
void worldSetup(emp::Ptr<YOURWorld> world, emp::Ptr<SymConfigBase> my_config) {
emp::Random& random = world->GetRandom();
double start_moi = my_config->START_MOI();
long unsigned int POP_SIZE;
if (my_config->POP_SIZE() == -1) {
POP_SIZE = my_config->GRID_X() * my_config->GRID_Y();
} else {
POP_SIZE = my_config->POP_SIZE();
}
bool random_phen_host = false;
bool random_phen_sym = false;
if(my_config->HOST_INT() == -2) random_phen_host = true;
if(my_config->SYM_INT() == -2) random_phen_sym = true;
if (my_config->GRID() == 0) {world->SetPopStruct_Mixed(false);}
else world->SetPopStruct_Grid(my_config->GRID_X(), my_config->GRID_Y(), false);
//inject hosts
for (size_t i = 0; i < POP_SIZE; i++){
emp::Ptr<YOURHost> new_org; //Change to your host type
if (random_phen_host) {new_org.New(&random, world, my_config, random.GetDouble(-1, 1));
} else { new_org.New(&random, world, my_config, my_config->HOST_INT());
}
if(my_config->GRID()) {
world->AddOrgAt(new_org, emp::WorldPosition(world->GetRandomCellID()));
} else {
world->AddOrgAt(new_org, world->size());
}
}
//sets up the world size
world->Resize(my_config->GRID_X(), my_config->GRID_Y());
//This loop must be outside of the host generation loop since otherwise
//syms try to inject into mostly empty spots at first
int total_syms = POP_SIZE * start_moi;
for (int j = 0; j < total_syms; j++){
double sym_int = 0;
if (random_phen_sym) {sym_int = random.GetDouble(-1,1);}
else {sym_int = my_config->SYM_INT();}
//Change to your symbiont type below
emp::Ptr<YOURSymbiont> new_sym = emp::NewPtr<YOURSymbiont>(&random, world, my_config, sym_int, 0, 1);
world->InjectSymbiont(new_sym);
}
}
#endif
If you have any new configuration settings that influence how organisms are first created, you will need to do that here.
Native File¶
You’ll next need to make a .cc
file that sets everything up.
This file can be fairly simple if you aren’t making major changes from how Symbulation currently works.
You should first add a file to source/native
with the name symbulation_<name>.cc
where <name>
is whatever you are calling your mode.
This is the main source file that will allow the experiment to function.
In it, the world is created, set up according to the setup file, and is permitted to run for the specified number of updates.
Here is the template for this file:
#include "../YOUR_mode/YOURWorld.h"
#include "../YOUR_mode/YOURWorldSetup.cc"
#include "symbulation.h"
// This is the main function for the NATIVE version of this project.
int symbulation_main(int argc, char * argv[])
{
SymConfigBase config;
CheckConfigFile(config, argc, argv);
config.Write(std::cout);
emp::Random random(config.SEED());
YOURWorld world(random, &config);
YOURWorldSetup(&world, &config);
world.CreateDateFiles();
world.RunExperiment();
return 0;
}
/*
This definition guard prevents main from being defined twice during testing.
In testing, Catch will define a main function which will initiate tests
(including testing the symbulation_main function above).
*/
#ifndef CATCH_CONFIG_MAIN
int main(int argc, char * argv[]) {
return symbulation_main(argc, argv);
}
#endif
Of course, if you want to make changes to how the experiment is setup or run, you can make this file more complicated or write your own version of provided functions.
Experiment!¶
Once you have created your organisms and corresponding world, added a main source file, and added targets to the Makefile, you are ready to experiment!
Run make YOUR-mode
to build your code (and probably solve some bugs along the way).