From decb8f262edc961d37a5d656b89fc1224286679c Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Wed, 31 Dec 2025 16:47:16 -0800 Subject: [PATCH 1/8] Implement Jaro-Winkler for unequal genome lengths Add handling for unequal genome lengths using Jaro-Winkler distance. --- src/genome-compare.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/genome-compare.cpp b/src/genome-compare.cpp index da2b93c0..edcf8d6e 100644 --- a/src/genome-compare.cpp +++ b/src/genome-compare.cpp @@ -135,6 +135,11 @@ float hammingDistanceBytes(const Genome &genome1, const Genome &genome2) // ToDo: optimize by approximation for long genomes float genomeSimilarity(const Genome &g1, const Genome &g2) { + // If genomes have different lengths, use Jaro-Winkler (method 0) which handles unequal lengths + if (g1.size() != g2.size()) { + return jaro_winkler_distance(g1, g2); + } + switch (p.genomeComparisonMethod) { case 0: return jaro_winkler_distance(g1, g2); From e12cefcfd8dbebf7ba93fe022df04e4b88297cd0 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Thu, 1 Jan 2026 09:17:07 -0800 Subject: [PATCH 2/8] Fix NUM_ACTIONS marker position in sensors-actions.h --- src/sensors-actions.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sensors-actions.h b/src/sensors-actions.h index 30b768af..c5c936bd 100644 --- a/src/sensors-actions.h +++ b/src/sensors-actions.h @@ -82,8 +82,8 @@ enum Action { MOVE_LEFT, // W MOVE_RIGHT, // W MOVE_REVERSE, // W - NUM_ACTIONS, // <<----------------- END OF ACTIVE ACTIONS MARKER KILL_FORWARD, // W + NUM_ACTIONS, // <<----------------- END OF ACTIVE ACTIONS MARKER }; extern std::string sensorName(Sensor sensor); From e93c5f92aa212cce23878ecbf839cfc7731005e8 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 06:43:00 -0800 Subject: [PATCH 3/8] Refactor genome comparison logic and add length penalty Refactor genome comparison functions to use standard algorithms and improve readability. Added length penalty for similarity calculation when genomes are of unequal lengths. --- src/genome-compare.cpp | 62 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/src/genome-compare.cpp b/src/genome-compare.cpp index edcf8d6e..5bad2959 100644 --- a/src/genome-compare.cpp +++ b/src/genome-compare.cpp @@ -1,6 +1,7 @@ // genome-compare.cpp -- compute similarity of two genomes #include +#include #include "simulator.h" namespace BS { @@ -26,8 +27,6 @@ bool genesMatch(const Gene &g1, const Gene &g2) // float jaro_winkler_distance(const Genome &genome1, const Genome &genome2) { float dw; - auto max = [](int a, int b) { return a > b ? a : b; }; - auto min = [](int a, int b) { return a < b ? a : b; }; const auto &s = genome1; const auto &a = genome2; @@ -38,19 +37,19 @@ float jaro_winkler_distance(const Genome &genome1, const Genome &genome2) { int al = a.size(); // strlen(a); constexpr unsigned maxNumGenesToCompare = 20; - sl = min(maxNumGenesToCompare, sl); // optimization: approximate for long genomes - al = min(maxNumGenesToCompare, al); + sl = std::min((int)maxNumGenesToCompare, sl); // optimization: approximate for long genomes + al = std::min((int)maxNumGenesToCompare, al); std::vector sflags(sl, 0); std::vector aflags(al, 0); - int range = max(0, max(sl, al) / 2 - 1); + int range = std::max(0, std::max(sl, al) / 2 - 1); if (!sl || !al) return 0.0; /* calculate matching characters */ for (i = 0; i < al; i++) { - for (j = max(i - range, 0), l = min(i + range + 1, sl); j < l; j++) { + for (j = std::max(i - range, 0), l = std::min(i + range + 1, sl); j < l; j++) { if (genesMatch(a[i], s[j]) && !sflags[j]) { sflags[j] = 1; aflags[i] = 1; @@ -73,7 +72,7 @@ float jaro_winkler_distance(const Genome &genome1, const Genome &genome2) { break; } } - if (!genesMatch(a[i], s[j])) + if (j < sl && !genesMatch(a[i], s[j])) // Fixed: check bounds t++; } } @@ -81,7 +80,24 @@ float jaro_winkler_distance(const Genome &genome1, const Genome &genome2) { /* Jaro distance */ dw = (((float)m / sl) + ((float)m / al) + ((float)(m - t) / m)) / 3.0f; - return dw; + + // Winkler prefix bonus: boost similarity if genomes start similarly + constexpr int maxPrefixLength = 4; + int prefixLength = std::min(maxPrefixLength, std::min(sl, al)); + int matchingPrefix = 0; + for (int i = 0; i < prefixLength; i++) { + if (genesMatch(s[i], a[i])) { + matchingPrefix++; + } else { + break; + } + } + + // Winkler scaling factor (typically 0.1) + constexpr float winklerScaling = 0.1f; + float winklerBonus = winklerScaling * matchingPrefix * (1.0f - dw); + + return std::min(1.0f, dw + winklerBonus); } @@ -135,21 +151,41 @@ float hammingDistanceBytes(const Genome &genome1, const Genome &genome2) // ToDo: optimize by approximation for long genomes float genomeSimilarity(const Genome &g1, const Genome &g2) { + float similarity; + // If genomes have different lengths, use Jaro-Winkler (method 0) which handles unequal lengths if (g1.size() != g2.size()) { - return jaro_winkler_distance(g1, g2); + similarity = jaro_winkler_distance(g1, g2); + + // Add length penalty to prevent convergence to extreme lengths + // Penalize based on relative length difference + float len1 = (float)g1.size(); + float len2 = (float)g2.size(); + float lengthRatio = std::min(len1, len2) / std::max(len1, len2); + + // Apply penalty: 80% weight on similarity, 20% on length ratio + // This prevents genomes from diverging too much in length + similarity = similarity * 0.8f + lengthRatio * 0.2f; + + return similarity; } - + switch (p.genomeComparisonMethod) { case 0: - return jaro_winkler_distance(g1, g2); + similarity = jaro_winkler_distance(g1, g2); + break; case 1: - return hammingDistanceBits(g1, g2); + similarity = hammingDistanceBits(g1, g2); + break; case 2: - return hammingDistanceBytes(g1, g2); + similarity = hammingDistanceBytes(g1, g2); + break; default: assert(false); + similarity = 0.0f; } + + return similarity; } From 58725a47ad45728bf59cec0d0cbe7fac35e60e07 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 21:02:19 -0800 Subject: [PATCH 4/8] 1.0.0 - Variable-Length Genome Stability --- .gitignore | 3 +- .vscode/settings.json | 3 + CMakeLists.txt | 2 +- README.md | 5 +- biosim4.ini | 219 ++++++++++++++++++++----------------- src/analysis.cpp | 93 +++++++++++++--- src/genome-compare.cpp | 16 ++- src/genome-neurons.h | 1 + src/genome.cpp | 78 ++++++++++++- src/imageWriter.cpp | 68 ++++++++++-- src/params.cpp | 12 +- src/params.h | 2 + src/spawnNewGeneration.cpp | 11 +- 13 files changed, 365 insertions(+), 148 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index c490026f..838d527f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ obj/ tools/*.svg -build/ \ No newline at end of file +build/ +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b0d06989 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": true +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 05adaf05..f2648b89 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.0.0) -project(BioSim4 VERSION 0.2.0) +project(BioSim4 VERSION 1.0.0) include(CTest) enable_testing() diff --git a/README.md b/README.md index 1793af52..0cc3da9b 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,7 @@ ## Status -This project is transitioning to maintenance-only. Thanks to all who contributed -improvements to this project. We will continue to welcome bug fixes that enable this -program to compile and execute, and we welcome discussions about this program -and related topics in the Issues section. +This project is a fork with some of my own changes and updates. See the release page for changes. ## What is this? diff --git a/biosim4.ini b/biosim4.ini index f57ac38e..645d0ed2 100644 --- a/biosim4.ini +++ b/biosim4.ini @@ -1,178 +1,193 @@ # biosim4.ini -# biosim4.ini is the default config file for the simulator. +# This is the default config file for Biosim4 (the C++ version of the simulator). # The config filename is determined in simulator() in simulator.cpp. -# The config file is parsed by class ParamManager, see params.cpp and params.h. +# The config file is parsed by ParamManager, see params.cpp. # Although not foolproof, the config file can be modified during a simulation # run and the param manager will make any new params available to the simulator # after the end of the current simulator step or after the end of the current # generation. # # Parameter values can also be changed automatically based on the current generation -# number of the simulation using the syntax (see barrierType param for example): +# number of the simulation using the syntax (see barriertype param for example): # @ = # Instances of the same parameter have to be ascending by generation number to work -# correctly, e.g. barrierType@100 must be later in the file than barrierType@50, -# which in turn must come after barrierType. Not all parameters can be safely +# correctly, e.g. barriertype@100 must be later in the file than barriertype@50, +# which in turn must come after barriertype. Not all parameters can be safely # changed during a simulation. Some restrictions are noted below. -# numThreads must be 1 or greater. Best value is less than or equal to +# numthreads must be 1 or greater. Best value is less than or equal to # the number of CPU cores. Cannot be changed after a simulation starts. -numThreads = 4 +numthreads = 8 -# sizeX, sizeY define the size of the 2D world. Minimum size is 16,16. +# sizex, sizey define the size of the 2D world. Minimum size is 16,16. # Maximum size is 32767, 32767. Cannot be changed after a simulation starts. -sizeX = 128 -sizeY = 128 +sizex = 128 +sizey = 128 -# Population at the start of each generation. Maximum value = 32766. +# population at the start of each generation. Maximum value = 32766. # Cannot be changed after a simulation starts. population = 3000 # Number of simulation steps per generation. Range 1..INT_MAX. -stepsPerGeneration = 300 +stepspergeneration = 300 -# The simulator will stop when the generation number == maxGenerations. +# The simulator will stop when the generation number == maxgenerations. # Range 1..INT_MAX -maxGenerations = 200000 +maxgenerations = 10000 -# genomeInitialLengthMin and genomeInitialLengthMax should be set to +# genomeinitiallengthmin and genomeinitiallengthmax should be set to # the same value. (For future use, the max length might be larger to # allow mutations that lengthen the genome.) Range 1..INT_MAX and -# must be no larger than genomeMaxLength. The range of genomeMaxLength -# is genomeInitialLengthMax..INT_MAX. Cannot be changed after a +# must be no larger than genomemaxlength. The range of genomemaxlength +# is genomeinitiallengthmax..INT_MAX. Cannot be changed after a # simulation starts. -genomeInitialLengthMin = 24 -genomeInitialLengthMax = 24 -genomeMaxLength = 300 +genomeinitiallengthmin = 24 +genomeinitiallengthmax = 24 +genomemaxlength = 300 -# maxNumberNeurons is the maximum number of internal neurons that may +# maxnumberneurons is the maximum number of internal neurons that may # be addressed by genes in the genome. Range 1..INT_MAX. Cannot be -# changed after a simulation starts. Cannot be changed after a -# simulation starts. -maxNumberNeurons = 5 +# changed after a simulation starts. +maxnumberneurons = 28 -# If killEnable is true and the "kill" action neuron is enabled in +# If killenable is true and the "kill" action neuron is enabled in # sensors-actions.h and compiled in, then agents are permitted to # kill their neighbor in the adjacent location in the direction of # forward movement. If false, the neighbors are safe. -killEnable = false +killenable = false -# If sexualReproduction is false, newborns inherit the genes from a +# If sexualreproduction is false, newborns inherit the genes from a # single parent. If true, newborns inherit a mixture of genes from # two parents. -sexualReproduction = true +sexualreproduction = true -# If chooseParentByFitness is false, then every agent that survives the +# If chooseparentsbyfitness is false, then every agent that survives the # selection criterion has equal chance of reproducing. If true, then # preference is given to those parents who passed the selection criterion # with a greater score. Fitness scores are determined in survival-criteria.cpp. -chooseParentsByFitness = true +chooseparentsbyfitness = true -# pointMutationRate is the probability per gene of having a single-bit +# pointmutationrate is the probability per gene of having a single-bit # mutation during spawning. Range 0.0 .. 1.0. A reasonable range is # 0.0001 to 0.01. -pointMutationRate = 0.001 - -# geneInsertionDeletionRate and deletionRatio are for future use to -# allow mutations that lengthen or shorten the genome. Ignored for now. -geneInsertionDeletionRate = 0.0 -deletionRatio = 0.5 - -# responsivenessCurveKFactor is a small positive integer that determines +pointmutationrate = 0.01 + +# geneinsertiondeletionrate is the probability per genome of having a gene +# inserted or deleted during reproduction. Range 0.0 .. 1.0. +# A reasonable range is 0.0001 to 0.01. Set to 0.0 to disable. +# For variable-length genome testing, 0.01 provides controlled, gradual changes. +# deletionratio is the probability that a mutation will be a deletion vs insertion. +# Range 0.0 .. 1.0. 0.5 means equal chance of insertion or deletion. +# Note: Insertion probability automatically scales down as genome length increases. +geneinsertiondeletionrate = 0.01 +deletionratio = 0.5 + +# fitnesslengthnormalization normalizes fitness scores by genome length to prevent +# selection pressure favoring longer genomes. Formula: normalized_score = score / (1 + beta * length) +# Range 0.0 .. 1.0. Higher values = stronger penalty for longer genomes. +# Recommended: 0.01 to 0.05. Default: 0.01 +fitnesslengthnormalization = 0.03 + +# responsivenesscurvekfactor is a small positive integer that determines # the shape of the curve that determines how reactive an agent is to its -# sensory inputs. Typical values are # 1, 2, 3, or 4, but greater values +# sensory inputs. Typical values are 1, 2, 3, or 4, but greater values # are allowed experimentally. -responsivenessCurveKFactor = 2 +responsivenesscurvekfactor = 2 -# populationSensorRadius is the radius in which the population sensor +# populationsensorradius is the radius in which the population sensor # looks for neighbors. Floating point value. A value of 1.5 includes # all the immediate eight-neighborhood. Larger values incur exponentially -# increasing processor overhead. Range 0.5 up to (float)max(sizeX, sizeY). -populationSensorRadius = 2.5 +# increasing processor overhead. Range 0.5 up to (float)max(sizex, sizey). +populationsensorradius = 2.5 -# longProbeDistance is the default distance that the long-probe sensors +# longprobedistance is the default distance that the long-probe sensors # are able to see. Applies to long-probe population sensor and long-probe # signal (pheromone) sensor. Range 1..INT_MAX. -longProbeDistance = 16 +longprobedistance = 16 -# shortProbeBarrierDistance is the distance that the short-probe sensor +# shortprobebarrierdistance is the distance that the short-probe sensor # can see. Range 1..INT_MAX. -shortProbeBarrierDistance = 4 +shortprobebarrierdistance = 4 -# signalSensorRadius is the radius in which the signal (pheromone) sensor +# signalsensorradius is the radius in which the signal (pheromone) sensor # looks for pheromones. Floating point value. A value of 1.5 includes # all the immediate eight-neighborhood. Larger values incur exponentially -# increasing processor overhead. Range 0.5 up to (float)max(sizeX, sizeY). -signalSensorRadius = 2.0 +# increasing processor overhead. Range 0.5 up to (float)max(sizex, sizey). +signalsensorradius = 2.0 -# signalLayers defines the number of pheromone layers. Must be 1 for now. +# signallayers defines the number of pheromone layers. Must be 1 for now. # Values > 1 are for future use. Cannot be changed after a simulation starts. -signalLayers = 1 +signallayers = 1 -# imageDir is the relative or absolute directory path where generation +# imagedir is the relative or absolute directory path where generation # movies are created. -imageDir = images +imagedir = images -# logDir is the relative or absolute directory path where text log files +# logdir is the relative or absolute directory path where text log files # are created. -logDir = logs +logdir = logs -# displayScale scales the generation movie. Typical values are +# displayscale scales the generation movie. Typical values are # 1 for actual size, or 2, 4, 8, 16, or 32 to scale up the movie. -displayScale = 8 +displayscale = 8 + +# agentsize controls the size of the dot used to represent an agent +# in the generation movie. Typical value is displayscale / 2. +agentsize = 4 -# agentSize controls the size of the dot used to represent an agent -# in the generation movie. Typical value is displayScale / 2. -agentSize = 4 +# If savevideo is true, the simulator program will create generation +# movies in the directory named by imagedir at the intervals set by +# videosavefirstframes and videostride. +savevideo = false -# If videoSaveFirstFrames is 0, then only the parameter videoStride controls -# how often generation movies are made. If videoSaveFirstFrames is nonzero, +# If savepngframes is true, individual PNG frame files will be saved +# for each frame. If false, only video files are created (no PNG frames). +# Set to false to avoid PNG save errors and reduce disk I/O. +savepngframes = false + +# videostride determines how often generation movies will be created. +# Also see savevideo and videosavefirstframes. Range 1..INT_MAX. +videostride = 25 + +# If videosavefirstframes is 0, then only the parameter videostride controls +# how often generation movies are made. If videosavefirstframes is nonzero, # then generation movies will also be generated for every generation from 0 -# through videoSaveFirstFrames (because the first few generations are often +# through videosavefirstframes (because the first few generations are often # the most interesting). Range 1..INT_MAX. -videoSaveFirstFrames = 2 +videosavefirstframes = 2 -# updateGraphLog can be set to true to cause the simulator program to +# updategraphlog can be set to true to cause the simulator program to # invoke graphlog.gp to update the simulation progress graph. If true, -# then updateGraphLogStride controls how often it is invoked. If -# updateGraphLog is false, then the simulator program will not invoke +# then updategraphlogstride controls how often it is invoked. If +# updategraphlog is false, then the simulator program will not invoke # graphlog.gp. -updateGraphLog = true - -# If saveVideo is true, the simulator program will create generation -# movies in the directory named by imageDir at the intervals set by -# videoSaveFirstFrames and videoStride. -saveVideo = true - -# videoStride determines how often generation movies will be created. -# Also see saveVideo and videoSaveFirstFrames. Range 1..INT_MAX. -videoStride = 25 +updategraphlog = true -# updateGraphLogStride determines how often the simulation progress graph -# is updated by direct invocation of graphlog.gp. Ignored if updateGraphLog -# is false. updateGraphLogStride may be a positive integer from 1 to INT_MAX, -# or may be set to the string videoStride to use the value of videoStride. -updateGraphLogStride = videoStride +# updategraphlogstride determines how often the simulation progress graph +# is updated by direct invocation of graphlog.gp. Ignored if updategraphlog +# is false. updategraphlogstride may be a positive integer from 1 to INT_MAX, +# or may be set to the string videoStride to use the value of videostride. +updategraphlogstride = videoStride -# genomeAnalysisStride determines how often the simulator will print genomic +# genomeanalysisstride determines how often the simulator will print genomic # statistics. The stats are printed to stdout when the generation number -# modulo genomeAnalysisStride == 0. The value may be a positive integer from +# modulo genomeanalysisstride == 0. The value may be a positive integer from # 1 to INT_MAX, or may be set to the string videoStride to use the value of -# videoStride. -genomeAnalysisStride = videoStride +# videostride. +genomeanalysisstride = videoStride -# When the genomic statistics are printed (see genomeAnalysisStride), the +# When the genomic statistics are printed (see genomeanalysisstride), the # method used to measure genome diversity in the population is determined -# by genomeComparisonMethod. May be set to 0 for Jaro-Winkler method (useful -# for future use if genomes are allowed to grow or shrink in size); or 1 +# by genomecomparisonmethod. May be set to 0 for Jaro-Winkler method (useful +# for variable-length genomes that can grow or shrink in size); or 1 # for a Hamming measure bit-by-bit, or 2 for a Hamming measure byte-by-byte. -# Typically set to 1. -genomeComparisonMethod = 1 +# Set to 0 for variable-length genome testing (required when geneinsertiondeletionrate > 0.0). +genomecomparisonmethod = 0 -# When genomic statistics are printed (see genomeAnalysisStride), the number +# When genomic statistics are printed (see genomeanalysisstride), the number # of genomes sampled from the population and printed to stdout is determined -# by displaySampleGenomes. Range 0 to population size. -displaySampleGenomes = 5 +# by displaysamplegenomes. Range 0 to population size. +displaysamplegenomes = 5 # challenge determines the selection criterion for reproduction. This is # typically always under active development. See survival-criteria.cpp for @@ -188,7 +203,7 @@ displaySampleGenomes = 5 # 7 = migrate distance # 8 = center sparse # 9 = left eighth -# 10 = radioactive walls +# 10 = radioactive walls (Left side then Right side) # 11 = against any wall # 12 = touch any wall any time # 13 = east-west eighths @@ -200,7 +215,7 @@ challenge = 6 # The simulator supports a feature called "barriers." Barriers are locations # in the simulated 2D world where agents may not occupy. The value of -# barrierType is typically under active development. See createBarrier.cpp +# barriertype is typically under active development. See createBarrier.cpp # for more information. # 0 = none # 1 = vertical bar constant location @@ -209,15 +224,15 @@ challenge = 6 # 4 = horiz bar constant location north center # 5 = floating islands # 6 = sequence of spots -barrierType = 0 +barriertype = 0 # This is an example of an automatic parameter change based on the generation. # If uncommented, the barrier type will automatically change to the new value # when the simulation reaches the generation specified after the @ delimiter. -# barrierType@500 = 5 +# barriertype@500 = 5 # If true, then the random number generator (RNG) will be seeded by the value -# in RNGSeed, causing each thread to receive a deterministic sequence from +# in rngseed, causing each thread to receive a deterministic sequence from # the RNG. If false, the RNG will be randomly seeded and program output will # be non-deterministic. Cannot be changed after a simulation starts. deterministic = false @@ -225,5 +240,5 @@ deterministic = false # If deterministic is true, the random number generator will be seeded with # this value. If deterministic is false, this value is ignored. Legal values # are integers 0 to 4294967295. Cannot be changed after a simulation starts. -RNGSeed = 12345678 +rngseed = 12345678 diff --git a/src/analysis.cpp b/src/analysis.cpp index cc8f7e6b..98e6626f 100644 --- a/src/analysis.cpp +++ b/src/analysis.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "simulator.h" namespace BS { @@ -13,6 +14,11 @@ namespace BS { // This converts sensor numbers to descriptive strings. std::string sensorName(Sensor sensor) { + // Bounds check to prevent crashes + if (sensor >= Sensor::NUM_SENSES) { + return "unknown sensor " + std::to_string((int)sensor); + } + switch(sensor) { case AGE: return "age"; break; case BOUNDARY_DIST: return "boundary dist"; break; @@ -35,7 +41,7 @@ std::string sensorName(Sensor sensor) case SIGNAL0_FWD: return "signal 0 fwd"; break; case SIGNAL0_LR: return "signal 0 LR"; break; case GENETIC_SIM_FWD: return "genetic similarity fwd"; break; - default: assert(false); break; + default: return "unknown sensor " + std::to_string((int)sensor); break; } } @@ -43,6 +49,11 @@ std::string sensorName(Sensor sensor) // Converts action numbers to descriptive strings. std::string actionName(Action action) { + // Bounds check to prevent crashes + if (action >= Action::NUM_ACTIONS) { + return "unknown action " + std::to_string((int)action); + } + switch(action) { case MOVE_EAST: return "move east"; break; case MOVE_WEST: return "move west"; break; @@ -61,7 +72,7 @@ std::string actionName(Action action) case MOVE_RL: return "move R-L"; break; case MOVE_RANDOM: return "move random"; break; case SET_LONGPROBE_DIST: return "set longprobe dist"; break; - default: assert(false); break; + default: return "unknown action " + std::to_string((int)action); break; } } @@ -70,6 +81,11 @@ std::string actionName(Action action) // Useful for later processing by graph-nnet.py. std::string sensorShortName(Sensor sensor) { + // Bounds check to prevent crashes + if (sensor >= Sensor::NUM_SENSES) { + return "S" + std::to_string((int)sensor); + } + switch(sensor) { case AGE: return "Age"; break; case BOUNDARY_DIST: return "ED"; break; @@ -92,7 +108,7 @@ std::string sensorShortName(Sensor sensor) case SIGNAL0_FWD: return "Sfd"; break; case SIGNAL0_LR: return "Slr"; break; case GENETIC_SIM_FWD: return "Gen"; break; - default: assert(false); break; + default: return "S" + std::to_string((int)sensor); break; } } @@ -101,6 +117,11 @@ std::string sensorShortName(Sensor sensor) // Useful for later processing by graph-nnet.py. std::string actionShortName(Action action) { + // Bounds check to prevent crashes + if (action >= Action::NUM_ACTIONS) { + return "A" + std::to_string((int)action); + } + switch(action) { case MOVE_EAST: return "MvE"; break; case MOVE_WEST: return "MvW"; break; @@ -119,7 +140,7 @@ std::string actionShortName(Action action) case MOVE_RL: return "MRL"; break; case MOVE_RANDOM: return "Mrn"; break; case SET_LONGPROBE_DIST: return "LPD"; break; - default: assert(false); break; + default: return "A" + std::to_string((int)action); break; } } @@ -262,7 +283,10 @@ float averageGenomeLength() sum += peeps[randomUint(1, p.population)].genome.size(); ++numberSamples; } - return sum / numberSamples; + if (numberSamples == 0) { + return 0.0f; + } + return (float)sum / (float)numberSamples; } @@ -284,7 +308,7 @@ void appendEpochLog(unsigned generation, unsigned numberSurvivors, unsigned murd foutput << generation << " " << numberSurvivors << " " << geneticDiversity() << " " << averageGenomeLength() << " " << murderCount << std::endl; } else { - assert(false); + std::cerr << "Warning: Failed to open epoch log file for writing" << std::endl; } } @@ -321,33 +345,66 @@ void displaySensorActionReferenceCounts() std::vector actionCounts(Action::NUM_ACTIONS, 0); for (unsigned index = 1; index <= p.population; ++index) { - if (peeps[index].alive) { + // Bounds check: ensure index is valid before accessing peeps array + // Note: peeps array should be sized for population, but defensive check prevents crashes + if (index > 0xFFFF) { // Max uint16_t value + continue; + } + try { const Indiv &indiv = peeps[index]; - for (const Gene &gene : indiv.nnet.connections) { - if (gene.sourceType == SENSOR) { - assert(gene.sourceNum < Sensor::NUM_SENSES); - ++sensorCounts[(Sensor)gene.sourceNum]; - } - if (gene.sinkType == ACTION) { - assert(gene.sinkNum < Action::NUM_ACTIONS); - ++actionCounts[(Action)gene.sinkNum]; + if (indiv.alive) { + for (const Gene &gene : indiv.nnet.connections) { + if (gene.sourceType == SENSOR) { + unsigned sourceNum = gene.sourceNum; + if (sourceNum < Sensor::NUM_SENSES && sourceNum < sensorCounts.size()) { + ++sensorCounts[sourceNum]; + } + } + if (gene.sinkType == ACTION) { + unsigned sinkNum = gene.sinkNum; + if (sinkNum < Action::NUM_ACTIONS) { + ++actionCounts[sinkNum]; + } + } } } + } catch (const std::exception &e) { + // Skip invalid index to prevent crashes + std::cerr << "Warning: Error processing individual " << index << ": " << e.what() << std::endl; + continue; + } catch (...) { + // Skip invalid index to prevent crashes + std::cerr << "Warning: Unknown error processing individual " << index << std::endl; + continue; } } std::cout << "Sensors in use:" << std::endl; - for (unsigned i = 0; i < sensorCounts.size(); ++i) { + std::cout.flush(); + for (unsigned i = 0; i < sensorCounts.size() && i < (unsigned)Sensor::NUM_SENSES; ++i) { if (sensorCounts[i] > 0) { std::cout << " " << sensorCounts[i] << " - " << sensorName((Sensor)i) << std::endl; + std::cout.flush(); } } std::cout << "Actions in use:" << std::endl; - for (unsigned i = 0; i < actionCounts.size(); ++i) { + std::cout.flush(); + for (unsigned i = 0; i < actionCounts.size() && i < (unsigned)Action::NUM_ACTIONS; ++i) { if (actionCounts[i] > 0) { - std::cout << " " << actionCounts[i] << " - " << actionName((Action)i) << std::endl; + try { + std::string actionStr = actionName((Action)i); + std::cout << " " << actionCounts[i] << " - " << actionStr << std::endl; + std::cout.flush(); + } catch (const std::exception &e) { + std::cout << " " << actionCounts[i] << " - [error: " << e.what() << " for action " << i << "]" << std::endl; + std::cout.flush(); + } catch (...) { + std::cout << " " << actionCounts[i] << " - [unknown error for action " << i << "]" << std::endl; + std::cout.flush(); + } } } + std::cout.flush(); } diff --git a/src/genome-compare.cpp b/src/genome-compare.cpp index 5bad2959..31c875c6 100644 --- a/src/genome-compare.cpp +++ b/src/genome-compare.cpp @@ -163,9 +163,19 @@ float genomeSimilarity(const Genome &g1, const Genome &g2) float len2 = (float)g2.size(); float lengthRatio = std::min(len1, len2) / std::max(len1, len2); - // Apply penalty: 80% weight on similarity, 20% on length ratio - // This prevents genomes from diverging too much in length - similarity = similarity * 0.8f + lengthRatio * 0.2f; + // Add absolute length penalty: penalize genomes that deviate from initial length + // This creates selection pressure to maintain lengths near the starting value + float initialLength = (float)p.genomeInitialLengthMin; + float avgLength = (len1 + len2) / 2.0f; + float lengthDeviation = std::abs(avgLength - initialLength) / initialLength; + // Penalty increases quadratically with deviation (0.0 at initial, 1.0 at 2x initial) + float absolutePenalty = std::min(lengthDeviation / 2.0f, 1.0f); + float absoluteBonus = 1.0f - absolutePenalty; + + // Apply penalties: 30% similarity, 35% relative length ratio, 35% absolute length bonus + // Strengthened length penalties to better prevent genome length growth + // This triple penalty system prevents both relative divergence and absolute growth + similarity = similarity * 0.3f + lengthRatio * 0.35f + absoluteBonus * 0.35f; return similarity; } diff --git a/src/genome-neurons.h b/src/genome-neurons.h index 185a5796..aaa772c8 100644 --- a/src/genome-neurons.h +++ b/src/genome-neurons.h @@ -87,6 +87,7 @@ extern Genome makeRandomGenome(); extern void unitTestConnectNeuralNetWiringFromGenome(); extern float genomeSimilarity(const Genome &g1, const Genome &g2); // 0.0..1.0 extern float geneticDiversity(); // 0.0..1.0 +extern void deduplicateConnections(std::vector &connections); } // end namespace BS diff --git a/src/genome.cpp b/src/genome.cpp index 6495d26d..9673c428 100644 --- a/src/genome.cpp +++ b/src/genome.cpp @@ -2,10 +2,13 @@ #include #include +#include #include #include #include #include +#include +#include #include "simulator.h" #include "random.h" @@ -252,6 +255,10 @@ void Indiv::createWiringFromGenome() } } + // Deduplicate connections: merge duplicate source-sink pairs by summing weights + // This prevents genomes from growing indefinitely by adding redundant connections + deduplicateConnections(nnet.connections); + // Create the indiv's neural node list nnet.neurons.clear(); for (unsigned neuronNum = 0; neuronNum < nodeMap.size(); ++neuronNum) { @@ -265,6 +272,61 @@ void Indiv::createWiringFromGenome() // --------------------------------------------------------------------------- +// Deduplicate connections by merging duplicate source-sink pairs +// Key: (sourceType, sourceNum, sinkType, sinkNum) -> accumulated weight +// Weights are summed and clamped to i16 range +void deduplicateConnections(std::vector &connections) +{ + // Map key: (sourceType, sourceNum, sinkType, sinkNum) -> accumulated weight + std::unordered_map connectionMap; + + // Create a unique key from connection fields + // Note: sourceType and sinkType are 1-bit (0 or 1), sourceNum and sinkNum are 7-bit (0-127) + auto makeKey = [](const Gene &conn) -> uint32_t { + return (static_cast(conn.sourceType & 0x01) << 24) | + (static_cast(conn.sourceNum & 0x7F) << 16) | + (static_cast(conn.sinkType & 0x01) << 8) | + static_cast(conn.sinkNum & 0x7F); + }; + + // Sum weights for duplicate connections + for (const auto &conn : connections) { + uint32_t key = makeKey(conn); + connectionMap[key] += static_cast(conn.weight); + } + + // Rebuild connections list with deduplicated entries, clamping weights to i16 range + connections.clear(); + for (const auto &pair : connectionMap) { + uint32_t key = pair.first; + int32_t weightSum = pair.second; + + // Extract fields from key with proper masking for bitfields + uint8_t sourceType = static_cast((key >> 24) & 0x01); + uint8_t sourceNum = static_cast((key >> 16) & 0x7F); + uint8_t sinkType = static_cast((key >> 8) & 0x01); + uint8_t sinkNum = static_cast(key & 0x7F); + + // Clamp weight to i16 range + int16_t clampedWeight = static_cast( + std::max(static_cast(INT16_MIN), + std::min(static_cast(INT16_MAX), weightSum))); + + Gene newConn; + newConn.sourceType = sourceType; + newConn.sourceNum = sourceNum; + newConn.sinkType = sinkType; + newConn.sinkNum = sinkNum; + newConn.weight = clampedWeight; + + connections.push_back(newConn); + } +} + + +// --------------------------------------------------------------------------- + + // This applies a point mutation at a random bit in a genome. void randomBitFlip(Genome &genome) { @@ -321,14 +383,24 @@ void randomInsertDeletion(Genome &genome) { float probability = p.geneInsertionDeletionRate; if (randomUint() / (float)RANDOM_UINT_MAX < probability) { - if (randomUint() / (float)RANDOM_UINT_MAX < p.deletionRatio) { + float genomeLength = (float)genome.size(); + float initialLength = (float)p.genomeInitialLengthMin; + + // Scale insertion probability down as genome grows beyond initial length + // At initial length: use normal deletionRatio + // At 2x initial length: insertion probability is halved + float lengthFactor = (genomeLength > initialLength) ? (initialLength / genomeLength) : 1.0f; + + // Adjusted deletion ratio: higher chance of deletion for longer genomes + float adjustedDeletionRatio = p.deletionRatio + (1.0f - lengthFactor) * (1.0f - p.deletionRatio); + + if (randomUint() / (float)RANDOM_UINT_MAX < adjustedDeletionRatio) { // deletion if (genome.size() > 1) { genome.erase(genome.begin() + randomUint(0, genome.size() - 1)); } } else if (genome.size() < p.genomeMaxLength) { - // insertion - //genome.insert(genome.begin() + randomUint(0, genome.size() - 1), makeRandomGene()); + // insertion (probability already reduced via adjustedDeletionRatio) genome.push_back(makeRandomGene()); } } diff --git a/src/imageWriter.cpp b/src/imageWriter.cpp index c59d2ecb..3f8e9f62 100644 --- a/src/imageWriter.cpp +++ b/src/imageWriter.cpp @@ -28,11 +28,15 @@ void saveOneFrameImmed(const ImageFrameData &data) 3, // color channels 255); // initial value uint8_t color[3]; + + // Only create filename if PNG frame saving is enabled std::stringstream imageFilename; - imageFilename << p.imageDir << "frame-" - << std::setfill('0') << std::setw(6) << data.generation - << '-' << std::setfill('0') << std::setw(6) << data.simStep - << ".png"; + if (p.savePngFrames) { + imageFilename << p.imageDir << "frame-" + << std::setfill('0') << std::setw(6) << data.generation + << '-' << std::setfill('0') << std::setw(6) << data.simStep + << ".png"; + } // Draw barrier locations @@ -73,8 +77,22 @@ void saveOneFrameImmed(const ImageFrameData &data) 1.0); // alpha } - //image.save_png(imageFilename.str().c_str(), 3); - imageList.push_back(image); + // Save PNG frame only if enabled (disabled by default to avoid errors) + if (p.savePngFrames && !imageFilename.str().empty()) { + try { + image.save_png(imageFilename.str().c_str(), 3); + } catch (const std::exception& e) { + std::cerr << "Failed to save frame " << imageFilename.str() << ": " << e.what() << std::endl; + } catch (...) { + std::cerr << "Failed to save frame " << imageFilename.str() << std::endl; + } + } + // Always add to imageList for video generation (even if PNG saving is disabled) + // CImg should already be in correct format for OpenCV (CV_8U) + // but ensure dimensions are valid + if (image.width() > 0 && image.height() > 0 && image.spectrum() == 3) { + imageList.push_back(image); + } //CImgDisplay local(image, "biosim3"); } @@ -204,12 +222,38 @@ void ImageWriter::saveGenerationVideo(unsigned generation) videoFilename << p.imageDir.c_str() << "/gen-" << std::setfill('0') << std::setw(6) << generation << ".avi"; - cv::setNumThreads(2); - imageList.save_video(videoFilename.str().c_str(), - 25, - "H264"); - if (skippedFrames > 0) { - std::cout << "Video skipped " << skippedFrames << " frames" << std::endl; + try { + cv::setNumThreads(2); + // Verify images are valid before attempting to save + if (imageList.size() > 0) { + const auto &firstImage = imageList[0]; + if (firstImage.width() > 0 && firstImage.height() > 0 && firstImage.spectrum() == 3) { + // CImg's save_video uses OpenCV internally, which requires CV_8U format + // The images are already CImg, so they should be correct + // However, there may be an issue with CImg's OpenCV conversion + imageList.save_video(videoFilename.str().c_str(), + 25, + "H264"); + if (skippedFrames > 0) { + std::cout << "Video skipped " << skippedFrames << " frames" << std::endl; + } + } else { + std::cerr << "Warning: Invalid image format (w=" << firstImage.width() + << " h=" << firstImage.height() + << " channels=" << firstImage.spectrum() + << "), skipping video save for generation " << generation << std::endl; + } + } + } catch (const std::exception &e) { + std::cerr << "Error saving video " << videoFilename.str() + << ": " << e.what() << std::endl; + std::cerr << "This is likely an OpenCV/CImg compatibility issue." << std::endl; + std::cerr << "Tip: Set savevideo = false in biosim4.ini to disable video saving and continue simulation." << std::endl; + // Continue execution instead of crashing - simulation can proceed without videos + } catch (...) { + std::cerr << "Unknown error saving video " << videoFilename.str() << std::endl; + std::cerr << "Tip: Set savevideo = false in biosim4.ini to disable video saving and continue simulation." << std::endl; + // Continue execution instead of crashing } } startNewGeneration(); diff --git a/src/params.cpp b/src/params.cpp index 272f96cc..f920ab7a 100644 --- a/src/params.cpp +++ b/src/params.cpp @@ -41,6 +41,7 @@ void ParamManager::setDefaults() privParams.pointMutationRate = 0.001; privParams.geneInsertionDeletionRate = 0.0; privParams.deletionRatio = 0.5; + privParams.fitnessLengthNormalization = 0.03f; privParams.killEnable = false; privParams.sexualReproduction = true; privParams.chooseParentsByFitness = true; @@ -52,6 +53,7 @@ void ParamManager::setDefaults() privParams.shortProbeBarrierDistance = 4; privParams.valenceSaturationMag = 0.5; privParams.saveVideo = true; + privParams.savePngFrames = false; privParams.videoStride = 25; privParams.videoSaveFirstFrames = 2; privParams.displayScale = 8; @@ -186,6 +188,9 @@ void ParamManager::ingestParameter(std::string name, std::string val) else if (name == "deletionratio" && isFloat && dVal >= 0.0 && dVal <= 1.0) { privParams.deletionRatio = dVal; break; } + else if (name == "fitnesslengthnormalization" && isFloat && dVal >= 0.0) { + privParams.fitnessLengthNormalization = (float)dVal; break; + } else if (name == "killenable" && isBool) { privParams.killEnable = bVal; break; } @@ -219,6 +224,9 @@ void ParamManager::ingestParameter(std::string name, std::string val) else if (name == "savevideo" && isBool) { privParams.saveVideo = bVal; break; } + else if (name == "savepngframes" && isBool) { + privParams.savePngFrames = bVal; break; + } else if (name == "videostride" && isUint && uVal > 0) { privParams.videoStride = uVal; break; } @@ -234,7 +242,7 @@ void ParamManager::ingestParameter(std::string name, std::string val) else if (name == "genomeanalysisstride" && isUint && uVal > 0) { privParams.genomeAnalysisStride = uVal; break; } - else if (name == "genomeanalysisstride" && val == "videoStride") { + else if (name == "genomeanalysisstride" && (val == "videoStride" || val == "videostride")) { privParams.genomeAnalysisStride = privParams.videoStride; break; } else if (name == "displaysamplegenomes" && isUint) { @@ -249,7 +257,7 @@ void ParamManager::ingestParameter(std::string name, std::string val) else if (name == "updategraphlogstride" && isUint && uVal > 0) { privParams.updateGraphLogStride = uVal; break; } - else if (name == "updategraphlogstride" && val == "videoStride") { + else if (name == "updategraphlogstride" && (val == "videoStride" || val == "videostride")) { privParams.updateGraphLogStride = privParams.videoStride; break; } else if (name == "deterministic" && isBool) { diff --git a/src/params.h b/src/params.h index 6b25df45..5a5e57ad 100644 --- a/src/params.h +++ b/src/params.h @@ -32,6 +32,7 @@ struct Params { double pointMutationRate; // 0.0..1.0 double geneInsertionDeletionRate; // 0.0..1.0 double deletionRatio; // 0.0..1.0 + float fitnessLengthNormalization; // >= 0.0, normalizes fitness by genome length bool killEnable; bool sexualReproduction; bool chooseParentsByFitness; @@ -43,6 +44,7 @@ struct Params { unsigned shortProbeBarrierDistance; // > 0 float valenceSaturationMag; bool saveVideo; + bool savePngFrames; // if true, save individual PNG frame files unsigned videoStride; // > 0 unsigned videoSaveFirstFrames; // >= 0, overrides videoStride unsigned displayScale; diff --git a/src/spawnNewGeneration.cpp b/src/spawnNewGeneration.cpp index a633a68b..0daf5d1f 100644 --- a/src/spawnNewGeneration.cpp +++ b/src/spawnNewGeneration.cpp @@ -89,7 +89,11 @@ unsigned spawnNewGeneration(unsigned generation, unsigned murderCount) // possibly do a move here instead of copy, although it's doubtful that // the optimization would be noticeable. if (passed.first && !peeps[index].nnet.connections.empty()) { - parents.push_back( { index, passed.second } ); + // Normalize fitness by genome length to prevent selection pressure for longer genomes + // Formula: normalized_score = score / (1 + beta * genome_length) + float genomeLength = (float)peeps[index].genome.size(); + float normalizedScore = passed.second / (1.0f + p.fitnessLengthNormalization * genomeLength); + parents.push_back( { index, normalizedScore } ); } } } else { @@ -105,7 +109,10 @@ unsigned spawnNewGeneration(unsigned generation, unsigned murderCount) // This the test for the spawning area: std::pair passed = passedSurvivalCriterion(peeps[index], CHALLENGE_ALTRUISM); if (passed.first && !peeps[index].nnet.connections.empty()) { - parents.push_back( { index, passed.second } ); + // Normalize fitness by genome length + float genomeLength = (float)peeps[index].genome.size(); + float normalizedScore = passed.second / (1.0f + p.fitnessLengthNormalization * genomeLength); + parents.push_back( { index, normalizedScore } ); } else { // This is the test for the sacrificial area: passed = passedSurvivalCriterion(peeps[index], CHALLENGE_ALTRUISM_SACRIFICE); From 2e69b20b5af7fb944e6bdf9850efcc943137bdd1 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 21:05:10 -0800 Subject: [PATCH 5/8] Readme Update --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cc3da9b..0a0bb144 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ ## Status -This project is a fork with some of my own changes and updates. See the release page for changes. +This project is a fork with enhancements focused on improving variable-length genome stability and preventing artificial genome growth. Key improvements include: + +- **Connection deduplication**: Automatic merging of duplicate neural network connections during genome-to-network conversion, preventing genomes from growing indefinitely by adding redundant connections. Duplicate connections (same source-sink pairs) are merged by summing their weights and clamping to i16 range. + +- **Enhanced fitness normalization**: Increased default `fitnesslengthnormalization` from `0.01` to `0.03` to better prevent selection pressure favoring longer genomes. The normalization formula `normalized_score = score / (1 + beta * genome_length)` now applies a stronger penalty for longer genomes. + +- **Strengthened length penalty weights**: Updated genome similarity calculations to use a triple penalty system (30% similarity, 35% relative length ratio, 35% absolute length bonus) instead of the previous 40/30/30 split. This creates stronger selection pressure to maintain genome lengths near the initial value. + +- **Length-aware mutation scaling**: Enhanced insertion/deletion mutation rates to dynamically adjust based on genome length. As genomes grow beyond their initial length, insertion probability automatically decreases while deletion probability increases, creating natural selection pressure that favors genome lengths closer to the starting value. + ## What is this? From 26691428c3a67cde71840cb6076251537c5a97aa Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 21:10:05 -0800 Subject: [PATCH 6/8] Update .gitignore and LICENSE files - Added .vscode/ to .gitignore to exclude Visual Studio Code settings from version control. - Updated LICENSE file to reflect new copyright holder and year. --- .gitignore | 2 ++ LICENSE | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 838d527f..14ed8b45 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ obj/ tools/*.svg build/ .DS_Store +.vscode/ +.gitignore \ No newline at end of file diff --git a/LICENSE b/LICENSE index 2ddbb831..4733c3de 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License -Copyright (c) 2021-2022 David R. Miller and other contributors. For the -exact contribution history, see the revision logs at the project page +Copyright (c) 2025 Alexander Gates and other contributors. For the +original contribution history, see the revision logs at the original project page https://github.com/davidrmiller/biosim4. Permission is hereby granted, free of charge, to any person obtaining a copy From 46e00adba187c4d264b17599a2e466f680591b44 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 21:10:34 -0800 Subject: [PATCH 7/8] Delete .vscode directory --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index b0d06989..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": true -} \ No newline at end of file From 212c33477c326d294574e24b79a1b8ecd99c3f74 Mon Sep 17 00:00:00 2001 From: Alexander Gates Date: Fri, 2 Jan 2026 21:17:08 -0800 Subject: [PATCH 8/8] Update README to include Rust refactor link Added link to Rust refactor repository. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0a0bb144..8558d3d2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # biosim4 +Try out a Rust refactor here - https://github.com/AlexanderGatesDev/biosimRust + ## Status This project is a fork with enhancements focused on improving variable-length genome stability and preventing artificial genome growth. Key improvements include: