diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index 8a2d0e744d..f88b1dc85e 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -1162,7 +1162,7 @@ class EMConfig { enum class Algorithm { DEFAULT, SMARTS, MIO, RANDOM, WTS, MOSA, RW, - StandardGA, MonotonicGA, SteadyStateGA // These 3 are still work-in-progress + StandardGA, MonotonicGA, SteadyStateGA, BreederGA, CellularGA // GA variants still work-in-progress. } @Cfg("The algorithm used to generate test cases. The default depends on whether black-box or white-box testing is done.") @@ -2742,6 +2742,32 @@ class EMConfig { @Min(0.0) var elitesCount: Int = 1 + // Cellular GA neighborhood configuration + enum class CGANeighborhoodModel { + RING, L5, C9, C13 + } + + @Experimental + @Cfg("Cellular GA: neighborhood model (RING, L5, C9, C13)") + var cgaNeighborhoodModel: CGANeighborhoodModel = CGANeighborhoodModel.RING + + /** + * Breeder GA: truncation fraction to build parents pool P'. Range (0,1]. + */ + @Experimental + @Min(0.0) + @Max(1.0) + @Cfg("Breeder GA: fraction of top individuals to keep in parents pool (truncation).") + var breederTruncationFraction: Double = 0.5 + + /** + * Breeder GA: minimum number of parents to keep after truncation. + */ + @Experimental + @Min(1.0) + @Cfg("Breeder GA: minimum number of individuals in parents pool after truncation.") + var breederParentsMin: Int = 2 + @Experimental @Cfg("In REST APIs, when request Content-Type is JSON, POJOs are used instead of raw JSON string. " + "Only available for JVM languages") diff --git a/core/src/main/kotlin/org/evomaster/core/Main.kt b/core/src/main/kotlin/org/evomaster/core/Main.kt index f8705bd650..ca1bc7693e 100644 --- a/core/src/main/kotlin/org/evomaster/core/Main.kt +++ b/core/src/main/kotlin/org/evomaster/core/Main.kt @@ -645,6 +645,12 @@ class Main { EMConfig.Algorithm.StandardGA -> Key.get(object : TypeLiteral>() {}) + EMConfig.Algorithm.BreederGA -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.CellularGA -> + Key.get(object : TypeLiteral>() {}) + else -> throw IllegalStateException("Unrecognized algorithm ${config.algorithm}") } @@ -670,6 +676,12 @@ class Main { EMConfig.Algorithm.RW -> Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.BreederGA -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.CellularGA -> + Key.get(object : TypeLiteral>() {}) else -> throw IllegalStateException("Unrecognized algorithm ${config.algorithm}") } } @@ -694,6 +706,12 @@ class Main { EMConfig.Algorithm.RW -> Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.BreederGA -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.CellularGA -> + Key.get(object : TypeLiteral>() {}) else -> throw IllegalStateException("Unrecognized algorithm ${config.algorithm}") } } @@ -728,6 +746,12 @@ class Main { EMConfig.Algorithm.RW -> Key.get(object : TypeLiteral>() {}) + EMConfig.Algorithm.BreederGA -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.CellularGA -> + Key.get(object : TypeLiteral>() {}) + else -> throw IllegalStateException("Unrecognized algorithm ${config.algorithm}") } } diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithm.kt new file mode 100644 index 0000000000..98c8d6c094 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithm.kt @@ -0,0 +1,80 @@ +package org.evomaster.core.search.algorithms + +import org.evomaster.core.EMConfig +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import kotlin.math.max + +/** + * Breeder Genetic Algorithm (BGA) + * + * Differences vs Standard GA: + * - Uses truncation selection to build a parents pool P'. + * - At each step, creates two offspring from two random parents in P', + * then randomly selects ONE of the 2 offspring to add to the next population. + */ +class BreederGeneticAlgorithm : AbstractGeneticAlgorithm() where T : Individual { + + override fun getType(): EMConfig.Algorithm { + return EMConfig.Algorithm.BreederGA + } + + override fun searchOnce() { + beginGeneration() + frozenTargets = archive.notCoveredTargets() + val n = config.populationSize + + // Elitism base for next generation + val nextPop = formTheNextPopulation(population) + + // Build parents pool P' by truncation on current population + val parentsPool = buildParentsPoolByTruncation(population) + + while (nextPop.size < n) { + beginStep() + val p1 = randomness.choose(parentsPool) + val p2 = randomness.choose(parentsPool) + + // Work on copies + val o1 = p1.copy() + val o2 = p2.copy() + + if (randomness.nextBoolean(config.xoverProbability)) { + xover(o1, o2) + } + if (randomness.nextBoolean(config.fixedRateMutation)) { + mutate(o1) + } + if (randomness.nextBoolean(config.fixedRateMutation)) { + mutate(o2) + } + + // Randomly pick one child to carry over + var chosen = o1 + if (!randomness.nextBoolean()) { + chosen = o2 + } + nextPop.add(chosen) + + if (!time.shouldContinueSearch()) { + endStep() + break + } + endStep() + } + + population.clear() + population.addAll(nextPop) + endGeneration() + } + + private fun buildParentsPoolByTruncation(pop: List>): List> { + if (pop.isEmpty()) { + return pop + } + + val sorted = pop.sortedByDescending { score(it) } + val k = max(config.breederParentsMin, (sorted.size * config.breederTruncationFraction).toInt()) + return sorted.take(k) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithm.kt new file mode 100644 index 0000000000..d46ebda176 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithm.kt @@ -0,0 +1,97 @@ +package org.evomaster.core.search.algorithms + +import org.evomaster.core.EMConfig +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual + +/** + * Cellular GA faithful to the standard pseudocode. + * Neighborhood is mocked as a simple ring [left, self, right] for now. + */ +class CellularGeneticAlgorithm : AbstractGeneticAlgorithm() where T : Individual { + + override fun getType(): EMConfig.Algorithm { + return EMConfig.Algorithm.CellularGA + } + + override fun searchOnce() { + beginGeneration() + // Freeze targets for current generation + frozenTargets = archive.notCoveredTargets() + + val n = population.size + val next: MutableList> = mutableListOf() + + for (i in 0 until n) { + beginStep() + val p = population[i] + + val neighbors = getNeighborhood(i) + + // Cellular tournament selection within neighborhood + val x = neighborhoodTournament(neighbors) + val y = neighborhoodTournament(neighbors) + + val o1 = x.copy() + val o2 = y.copy() + if (randomness.nextBoolean(config.xoverProbability)) { + xover(o1, o2) + } + + var o: WtsEvalIndividual + if (score(o1) >= score(o2)) { + o = o1 + } else { + o = o2 + } + + if (randomness.nextBoolean(config.fixedRateMutation)) { + mutate(o) + } + + var bestLocal: WtsEvalIndividual + if (score(o) >= score(p)) { + bestLocal = o + } else { + bestLocal = p + } + next.add(bestLocal) + endStep() + } + + population.clear() + population.addAll(next) + endGeneration() + } + + /** + * Runs tournament selection restricted to a neighborhood subset. + */ + private fun neighborhoodTournament(neighbors: List>): WtsEvalIndividual { + val sel = selectionStrategy.select(neighbors, config.tournamentSize, randomness, ::score) + observers.forEach { it.onSelection(sel) } + return sel + } + + /** + * Returns the neighborhood list for a given index based on the configured model. + * The returned list always includes the current cell ("center") and neighbors, + * in the order customary for the chosen model. + */ + private fun getNeighborhood(index: Int): List> { + val model = config.cgaNeighborhoodModel + val neighborhood = Neighborhood(population.size) + if (model == EMConfig.CGANeighborhoodModel.RING) { + return neighborhood.ringTopology(population, index) + } + if (model == EMConfig.CGANeighborhoodModel.L5) { + return neighborhood.linearFive(population, index) + } + if (model == EMConfig.CGANeighborhoodModel.C9) { + return neighborhood.compactNine(population, index) + } + return neighborhood.compactThirteen(population, index) + } +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/NeighborModels.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/NeighborModels.kt new file mode 100644 index 0000000000..a2fd09d9dd --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/NeighborModels.kt @@ -0,0 +1,19 @@ +package org.evomaster.core.search.algorithms + +/** + * An interface that defines the four neighbourhood models used with the cGA + */ +interface NeighborModels { + + fun ringTopology(collection: List, position: Int): List + + fun linearFive(collection: List, position: Int): List + + fun compactNine(collection: List, position: Int): List + + fun compactThirteen(collection: List, position: Int): List +} + + + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/Neighborhood.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/Neighborhood.kt new file mode 100644 index 0000000000..1db115d251 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/Neighborhood.kt @@ -0,0 +1,92 @@ +package org.evomaster.core.search.algorithms + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import kotlin.math.sqrt + +/** + * Neighborhood utilities inspired by EvoSuite's Neighbourhood for Cellular GA. + * Returns original references (no copies) to be compatible with identity checks in tests. + */ +class Neighborhood(private val populationSize: Int) : NeighborModels> { + + /** + * Map grid coordinate (rowIndex, colIndex) to a valid 1D index in [0, populationSize), + * using wrap-around on both axes. + */ + private fun toWrappedLinearIndex(numCols: Int, rowIndex: Int, colIndex: Int): Int { + val numCells = populationSize + val numRows = (numCells + numCols - 1) / numCols + val wrappedRow = (rowIndex + numRows) % numRows + val wrappedCol = (colIndex + numCols) % numCols + val linearIndex = wrappedRow * numCols + wrappedCol + if (linearIndex < numCells) { + return linearIndex + } + return linearIndex % numCells + } + + override fun ringTopology(population: List>, index: Int): List> { + val n = populationSize + val left = population[(index - 1 + n) % n] + val self = population[index] + val right = population[(index + 1) % n] + return listOf(left, self, right) + } + + override fun linearFive(population: List>, index: Int): List> { + val n = populationSize + val cols = maxOf(1, sqrt(n.toDouble()).toInt()) + val row = index / cols + val col = index % cols + + val north = population[toWrappedLinearIndex(cols, row - 1, col)] + val south = population[toWrappedLinearIndex(cols, row + 1, col)] + val east = population[toWrappedLinearIndex(cols, row, col + 1)] + val west = population[toWrappedLinearIndex(cols, row, col - 1)] + val self = population[index] + return listOf(north, south, east, west, self) + } + + override fun compactNine(population: List>, index: Int): List> { + val n = populationSize + val cols = maxOf(1, sqrt(n.toDouble()).toInt()) + val row = index / cols + val col = index % cols + + val n1 = population[toWrappedLinearIndex(cols, row - 1, col)] + val s1 = population[toWrappedLinearIndex(cols, row + 1, col)] + val e1 = population[toWrappedLinearIndex(cols, row, col + 1)] + val w1 = population[toWrappedLinearIndex(cols, row, col - 1)] + val nw = population[toWrappedLinearIndex(cols, row - 1, col - 1)] + val sw = population[toWrappedLinearIndex(cols, row + 1, col - 1)] + val ne = population[toWrappedLinearIndex(cols, row - 1, col + 1)] + val se = population[toWrappedLinearIndex(cols, row + 1, col + 1)] + val self = population[index] + return listOf(n1, s1, e1, w1, nw, sw, ne, se, self) + } + + override fun compactThirteen(population: List>, index: Int): List> { + val n = populationSize + val cols = maxOf(1, sqrt(n.toDouble()).toInt()) + val row = index / cols + val col = index % cols + + val n1 = population[toWrappedLinearIndex(cols, row - 1, col)] + val s1 = population[toWrappedLinearIndex(cols, row + 1, col)] + val e1 = population[toWrappedLinearIndex(cols, row, col + 1)] + val w1 = population[toWrappedLinearIndex(cols, row, col - 1)] + val nw = population[toWrappedLinearIndex(cols, row - 1, col - 1)] + val sw = population[toWrappedLinearIndex(cols, row + 1, col - 1)] + val ne = population[toWrappedLinearIndex(cols, row - 1, col + 1)] + val se = population[toWrappedLinearIndex(cols, row + 1, col + 1)] + val nn = population[toWrappedLinearIndex(cols, row - 2, col)] + val ss = population[toWrappedLinearIndex(cols, row + 2, col)] + val ee = population[toWrappedLinearIndex(cols, row, col + 2)] + val ww = population[toWrappedLinearIndex(cols, row, col - 2)] + val self = population[index] + return listOf(n1, s1, e1, w1, nw, sw, ne, se, nn, ss, ee, ww, self) + } +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithm.kt index d28cbeeab1..bb569b189a 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithm.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithm.kt @@ -5,14 +5,13 @@ import org.evomaster.core.search.Individual import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual import org.evomaster.core.search.service.SearchAlgorithm import kotlin.math.max -//TODO: Note that this class is not fully tested. -// It needs to be thoroughly verified whether this truly adheres to the intended algorithm. /** * An implementation of the Steady-State Genetic Algorithm (SSGA). * * Unlike Standard GA, which replaces the entire population in each generation, * Steady-State GA updates the population incrementally by replacing a small number - * of individuals at a time (typically just 1 or 2). + * of individuals at a time: + * Only replaces selected parents with offspring if the offspring are better * * This class inherits from StandardGeneticAlgorithm to reuse shared components, * but overrides search behavior to follow steady-state principles. @@ -33,6 +32,12 @@ class SteadyStateGeneticAlgorithm : StandardGeneticAlgorithm() where T : I * - Replace the parents with the offspring only if the offspring are fitter. */ override fun searchOnce() { + // Lifecycle: start generation + beginGeneration() + // Freeze objectives for this generation + frozenTargets = archive.notCoveredTargets() + // Start single steady-state step + beginStep() // Select two parents from the population val p1 = tournamentSelection() val p2 = tournamentSelection() @@ -55,8 +60,8 @@ class SteadyStateGeneticAlgorithm : StandardGeneticAlgorithm() where T : I } // Only replace parents with offspring if the offspring are better - if (max(o1.calculateCombinedFitness(), o2.calculateCombinedFitness()) > - max(p1.calculateCombinedFitness(), p2.calculateCombinedFitness())) { + if (max(score(o1), score(o2)) > + max(score(p1), score(p2))) { // Replace both parents in the population population.remove(p1) @@ -64,5 +69,8 @@ class SteadyStateGeneticAlgorithm : StandardGeneticAlgorithm() where T : I population.add(o1) population.add(o2) } + // End step and generation + endStep() + endGeneration() } } diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithmTest.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithmTest.kt new file mode 100644 index 0000000000..08bfec0c87 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/BreederGeneticAlgorithmTest.kt @@ -0,0 +1,179 @@ +package org.evomaster.core.search.algorithms + +import com.google.inject.Injector +import com.google.inject.Key +import com.google.inject.Module +import com.google.inject.TypeLiteral +import com.netflix.governator.guice.LifecycleInjector +import org.evomaster.core.BaseModule +import org.evomaster.core.EMConfig +import org.evomaster.core.TestUtils +import org.evomaster.core.search.algorithms.onemax.OneMaxIndividual +import org.evomaster.core.search.algorithms.onemax.OneMaxModule +import org.evomaster.core.search.algorithms.onemax.OneMaxSampler +import org.evomaster.core.search.algorithms.observer.GARecorder +import org.evomaster.core.search.service.ExecutionPhaseController +import org.evomaster.core.search.service.Randomness +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* + +class BreederGeneticAlgorithmTest { + + private lateinit var injector: Injector + + @BeforeEach + fun setUp() { + injector = LifecycleInjector.builder() + .withModules(* arrayOf(OneMaxModule(), BaseModule())) + .build().createInjector() + } + + // Verifies that the Breeder GA can find the optimal solution for the OneMax problem + @Test + fun testBreederGeneticAlgorithmFindsOptimum() { + TestUtils.handleFlaky { + val breederGA = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val config = injector.getInstance(EMConfig::class.java) + config.maxEvaluations = 10000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + val epc = injector.getInstance(ExecutionPhaseController::class.java) + epc.startSearch() + val solution = breederGA.search() + epc.finishSearch() + + assertTrue(solution.individuals.size == 1) + assertEquals(OneMaxSampler.DEFAULT_N.toDouble(), solution.overall.computeFitnessScore(), 0.001) + } + } + + // Verifies that BGA forms next generation as elites + chosen children from truncation + @Test + fun testNextGenerationIsElitesPlusTruncationChildren() { + TestUtils.handleFlaky { + val breederGA = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + breederGA.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + injector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.elitesCount = 2 + config.xoverProbability = 1.0 + config.fixedRateMutation = 1.0 + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + breederGA.setupBeforeSearch() + + val pop = breederGA.getViewOfPopulation() + val expectedElites = pop.sortedByDescending { it.calculateCombinedFitness() }.take(2) + + breederGA.searchOnce() + + val nextPop = breederGA.getViewOfPopulation() + + // population size preserved + assertEquals(config.populationSize, nextPop.size) + + // elites are present in next population + assertTrue(nextPop.any { it === expectedElites[0] }) + assertTrue(nextPop.any { it === expectedElites[1] }) + + // number of iterations equals children added = populationSize - elites + val iterations = config.populationSize - config.elitesCount + assertEquals(iterations, rec.xoCalls.size) + + // each iteration produced (o1,o2); exactly one should be carried over + rec.xoCalls.forEach { (o1, o2) -> + assertTrue(nextPop.any { it === o1 } || nextPop.any { it === o2 }) + } + + // two mutations per iteration (one per offspring) + assertEquals(2 * iterations, rec.mutated.size) + } + } + + // Edge Case: CrossoverProbability=0 and MutationProbability=1 on BGA + @Test + fun testNoCrossoverWhenProbabilityZero_BGA() { + TestUtils.handleFlaky { + val breederGA = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + breederGA.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + config.populationSize = 4 + config.elitesCount = 0 + config.xoverProbability = 0.0 // disable crossover + config.fixedRateMutation = 1.0 // force mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + breederGA.setupBeforeSearch() + breederGA.searchOnce() + + val nextPop = breederGA.getViewOfPopulation() + assertEquals(config.populationSize, nextPop.size) + + // crossover disabled + assertEquals(0, rec.xoCalls.size) + // should apply two mutations per iteration (mutation probability = 1) + assertEquals(2 * config.populationSize, rec.mutated.size) + } + } + + // Edge Case: MutationProbability=0 and CrossoverProbability=1 on BGA + @Test + fun testNoMutationWhenProbabilityZero_BGA() { + TestUtils.handleFlaky { + val breederGA = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + breederGA.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + config.populationSize = 4 + config.elitesCount = 0 + config.xoverProbability = 1.0 // force crossover + config.fixedRateMutation = 0.0 // disable mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + breederGA.setupBeforeSearch() + breederGA.searchOnce() + + val nextPop = breederGA.getViewOfPopulation() + assertEquals(config.populationSize, nextPop.size) + + // crossovers happen once per iteration (mutation probability = 1) + assertEquals(config.populationSize, rec.xoCalls.size) + + // mutations disabled + assertEquals(0, rec.mutated.size) + } + } + + +} diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithmTest.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithmTest.kt new file mode 100644 index 0000000000..7540db685d --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/CellularGeneticAlgorithmTest.kt @@ -0,0 +1,175 @@ +package org.evomaster.core.search.algorithms + +import com.google.inject.Injector +import com.google.inject.Key +import com.google.inject.Module +import com.google.inject.TypeLiteral +import com.netflix.governator.guice.LifecycleInjector +import org.evomaster.core.BaseModule +import org.evomaster.core.EMConfig +import org.evomaster.core.TestUtils +import org.evomaster.core.search.algorithms.observer.GARecorder +import org.evomaster.core.search.algorithms.onemax.OneMaxIndividual +import org.evomaster.core.search.algorithms.onemax.OneMaxModule +import org.evomaster.core.search.algorithms.onemax.OneMaxSampler +import org.evomaster.core.search.service.ExecutionPhaseController +import org.evomaster.core.search.service.Randomness +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CellularGeneticAlgorithmTest { + + private lateinit var injector: Injector + + @BeforeEach + fun setUp() { + injector = LifecycleInjector.builder() + .withModules(* arrayOf(OneMaxModule(), BaseModule())) + .build().createInjector() + } + + // Verifies that a single cGA iteration builds the next population from neighborhood winners. + @Test + fun testNextGenerationIsLocalWinnersFromNeighborhood() { + TestUtils.handleFlaky { + val cga = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + cga.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + injector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 1.0 + config.fixedRateMutation = 1.0 + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + config.cgaNeighborhoodModel = EMConfig.CGANeighborhoodModel.RING + + cga.setupBeforeSearch() + val initialPop = cga.getViewOfPopulation() + + cga.searchOnce() + + val nextPop = cga.getViewOfPopulation() + val n = config.populationSize + + // population size preserved + assertEquals(n, nextPop.size) + + // For each position i, next[i] must be either the original parent or one of the two offspring + assertEquals(n, rec.xoCalls.size) + for (i in 0 until n) { + val (o1, o2) = rec.xoCalls[i] + val p0 = initialPop[i] + val chosen = nextPop[i] + assertTrue(chosen === p0 || chosen === o1 || chosen === o2) + } + } + } + + // Verifies that the Cellular GA can find the optimal solution for the OneMax problem + @Test + fun testCellularGeneticAlgorithmFindsOptimum() { + TestUtils.handleFlaky { + val cga = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val config = injector.getInstance(EMConfig::class.java) + config.maxEvaluations = 10000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + val epc = injector.getInstance(ExecutionPhaseController::class.java) + epc.startSearch() + val solution = cga.search() + epc.finishSearch() + + assertTrue(solution.individuals.size == 1) + assertEquals(OneMaxSampler.DEFAULT_N.toDouble(), solution.overall.computeFitnessScore(), 0.001) + } + } + + // Edge Case: CrossoverProbability=0 and MutationProbability=1 on CGA + @Test + fun testNoCrossoverWhenProbabilityZero_CGA() { + TestUtils.handleFlaky { + val cga = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + cga.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + injector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 0.0 // disable crossover + config.fixedRateMutation = 1.0 // force mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + config.cgaNeighborhoodModel = EMConfig.CGANeighborhoodModel.RING + + cga.setupBeforeSearch() + cga.searchOnce() + + val nextPop = cga.getViewOfPopulation() + val n = config.populationSize + assertEquals(n, nextPop.size) + // two selections per iteration + assertEquals(2 * n, rec.selections.size) + // crossover disabled + assertEquals(0, rec.xoCalls.size) + // should apply one mutations per iteration (mutation probability = 1) + assertEquals(n, rec.mutated.size) + } + } + + // Edge Case: MutationProbability=0 and CrossoverProbability=1 on CGA + @Test + fun testNoMutationWhenProbabilityZero_CGA() { + TestUtils.handleFlaky { + val cga = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val rec = GARecorder() + cga.addObserver(rec) + + val config = injector.getInstance(EMConfig::class.java) + injector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 1.0 // force crossover + config.fixedRateMutation = 0.0 // disable mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + config.cgaNeighborhoodModel = EMConfig.CGANeighborhoodModel.RING + + cga.setupBeforeSearch() + cga.searchOnce() + + val nextPop = cga.getViewOfPopulation() + val n = config.populationSize + assertEquals(n, nextPop.size) + // two selections per iteration + assertEquals(2 * n, rec.selections.size) + // one crossover per iteration (crossover probability = 1) + assertEquals(n, rec.xoCalls.size) + // mutation disabled + assertEquals(0, rec.mutated.size) + } + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/NeighborhoodTest.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/NeighborhoodTest.kt new file mode 100644 index 0000000000..cc5de2a563 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/NeighborhoodTest.kt @@ -0,0 +1,71 @@ +package org.evomaster.core.search.algorithms + +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.EvaluatedIndividual +import org.evomaster.core.search.algorithms.onemax.OneMaxIndividual +import org.evomaster.core.search.FitnessValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NeighborhoodTest { + + private fun populationOf(size: Int): List> { + val pop = mutableListOf>() + repeat(size) { + pop.add(WtsEvalIndividual(mutableListOf())) + } + return pop + } + + @Test + fun ringTopology_left_and_right() { + val pop = populationOf(16) + val n = Neighborhood(pop.size) + val neighbors = n.ringTopology(pop, 2) + assertEquals(pop[1], neighbors[0]) // left + assertEquals(pop[3], neighbors[2]) // right + } + + @Test + fun ringTopology_wrap_edges() { + val pop = populationOf(16) + val n = Neighborhood(pop.size) + val leftEdge = n.ringTopology(pop, 0) + assertEquals(pop[15], leftEdge[0]) + val rightEdge = n.ringTopology(pop, 15) + assertEquals(pop[0], rightEdge[2]) + } + + @Test + fun linearFive_cardinals() { + val pop = populationOf(16) + val n = Neighborhood(pop.size) + val neighbors = n.linearFive(pop, 5) + assertEquals(pop[1], neighbors[0]) // N + assertEquals(pop[9], neighbors[1]) // S + assertEquals(pop[6], neighbors[2]) // E + assertEquals(pop[4], neighbors[3]) // W + } + + @Test + fun compactNine_diagonals() { + val pop = populationOf(16) + val n = Neighborhood(pop.size) + val neighbors = n.compactNine(pop, 5) + assertEquals(pop[2], neighbors[6]) // NE + assertEquals(pop[0], neighbors[4]) // NW + assertEquals(pop[10], neighbors[7]) // SE + assertEquals(pop[8], neighbors[5]) // SW + } + + @Test + fun compactThirteen_secondRing() { + val pop = populationOf(16) + val n = Neighborhood(pop.size) + val neighbors = n.compactThirteen(pop, 10) + assertEquals(pop[2], neighbors[8]) // NN + assertEquals(pop[2], neighbors[9]) // SS (based on the Java test mapping) + } +} + + diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithmTest.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithmTest.kt new file mode 100644 index 0000000000..e352d7d32e --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/SteadyStateGeneticAlgorithmTest.kt @@ -0,0 +1,218 @@ +package org.evomaster.core.search.algorithms + +import com.google.inject.Injector +import com.google.inject.Key +import com.google.inject.Module +import com.google.inject.TypeLiteral +import com.netflix.governator.guice.LifecycleInjector +import org.evomaster.core.BaseModule +import org.evomaster.core.EMConfig +import org.evomaster.core.TestUtils +import org.evomaster.core.search.algorithms.onemax.OneMaxIndividual +import org.evomaster.core.search.algorithms.onemax.OneMaxModule +import org.evomaster.core.search.algorithms.onemax.OneMaxSampler +import org.evomaster.core.search.algorithms.observer.GARecorder +import org.evomaster.core.search.algorithms.strategy.FixedSelectionStrategy +import org.evomaster.core.search.service.ExecutionPhaseController +import org.evomaster.core.search.service.Randomness +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class SteadyStateGeneticAlgorithmTest { + + private lateinit var injector: Injector + + @BeforeEach + fun setUp() { + injector = LifecycleInjector.builder() + .withModules(* arrayOf(OneMaxModule(), BaseModule())) + .build().createInjector() + } + + // Verifies that the Steady-State GA can find the optimal solution for the OneMax problem + @Test + fun testSteadyStateAlgorithm() { + TestUtils.handleFlaky { + val steadyStateAlgorithm = injector.getInstance( + Key.get( + object : TypeLiteral>() {}) + ) + + val config = injector.getInstance(EMConfig::class.java) + config.maxEvaluations = 10000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + val epc = injector.getInstance(ExecutionPhaseController::class.java) + epc.startSearch() + val solution = steadyStateAlgorithm.search() + epc.finishSearch() + assertTrue(solution.individuals.size == 1) + assertEquals(OneMaxSampler.DEFAULT_N.toDouble(), solution.overall.computeFitnessScore(), 0.001) + } + } + + // Verifies steady-state replacement: replace parents only if children are better + @Test + fun testSteadyStateReplacementIfChildrenBetter() { + TestUtils.handleFlaky { + val fixedSel = FixedSelectionStrategy() + val (ga, localInjector) = createGAWithSelection(fixedSel) + + val rec = GARecorder() + ga.addObserver(rec) + + val config = localInjector.getInstance(EMConfig::class.java) + localInjector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 1.0 + config.fixedRateMutation = 1.0 + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + ga.setupBeforeSearch() + + val pop = ga.getViewOfPopulation() + + // Select 2 deterministic parents from the initial population + val p1 = pop[0] + val p2 = pop[1] + fixedSel.setOrder(listOf(p1, p2)) + + ga.searchOnce() + + val nextPop = ga.getViewOfPopulation() + + // Size preserved + assertEquals(config.populationSize, nextPop.size) + + // Exactly two selections + assertEquals(2, rec.selections.size) + + // Crossover was called, capture offspring + assertEquals(1, rec.xoCalls.size) + val (o1, o2) = rec.xoCalls[0] + + // Replacement rule: keep offspring only if better than parents + val parentBest = kotlin.math.max(ga.score(p1), ga.score(p2)) + val childBest = kotlin.math.max(ga.score(o1), ga.score(o2)) + + if (childBest > parentBest) { + assertTrue(nextPop.any { it === o1 }) + assertTrue(nextPop.any { it === o2 }) + assertFalse(nextPop.containsAll(listOf(p1, p2))) + } else { + assertTrue(nextPop.any { it === p1 }) + assertTrue(nextPop.any { it === p2 }) + assertFalse(nextPop.containsAll(listOf(o1, o2))) + } + + // Mutation applied twice to offspring + assertEquals(2, rec.mutated.size) + assertTrue(rec.mutated.any { it === o1 }) + assertTrue(rec.mutated.any { it === o2 }) + } + } + + // Edge Case: CrossoverProbability=0 on SSGA + @Test + fun testNoCrossoverWhenProbabilityZero_SSGA() { + TestUtils.handleFlaky { + val fixedSel = FixedSelectionStrategy() + val (ga, localInjector) = createGAWithSelection(fixedSel) + + val rec = GARecorder() + ga.addObserver(rec) + + val config = localInjector.getInstance(EMConfig::class.java) + localInjector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 0.0 // disable crossover + config.fixedRateMutation = 1.0 // force mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + ga.setupBeforeSearch() + // Provide a deterministic selection order for the 2 selections in SSGA + val init = ga.getViewOfPopulation() + fixedSel.setOrder(listOf(init[0], init[1])) + ga.searchOnce() + + // population size preserved + val nextPop = ga.getViewOfPopulation() + assertEquals(config.populationSize, nextPop.size) + + // exactly two selections in one steady-state step + assertEquals(2, rec.selections.size) + // crossover disabled + assertEquals(0, rec.xoCalls.size) + // two mutations (one per offspring) + assertEquals(2, rec.mutated.size) + } + } + + // Edge Case: MutationProbability=0 on SSGA + @Test + fun testNoMutationWhenProbabilityZero_SSGA() { + TestUtils.handleFlaky { + val fixedSel = FixedSelectionStrategy() + val (ga, localInjector) = createGAWithSelection(fixedSel) + + val rec = GARecorder() + ga.addObserver(rec) + + val config = localInjector.getInstance(EMConfig::class.java) + localInjector.getInstance(Randomness::class.java).updateSeed(42) + + config.populationSize = 4 + config.xoverProbability = 1.0 // force crossover + config.fixedRateMutation = 0.0 // disable mutation + config.gaSolutionSource = EMConfig.GASolutionSource.POPULATION + config.maxEvaluations = 100_000 + config.stoppingCriterion = EMConfig.StoppingCriterion.ACTION_EVALUATIONS + + ga.setupBeforeSearch() + val init = ga.getViewOfPopulation() + fixedSel.setOrder(listOf(init[0], init[1])) + ga.searchOnce() + + val nextPop = ga.getViewOfPopulation() + assertEquals(config.populationSize, nextPop.size) + + // two selections, one crossover, zero mutations + assertEquals(2, rec.selections.size) + assertEquals(1, rec.xoCalls.size) + assertEquals(0, rec.mutated.size) + } + } + + + +} + +// --- Test helpers --- + +// --- Test helpers --- + +private fun createGAWithSelection( + fixedSel: FixedSelectionStrategy +): Pair, Injector> { + val injector = LifecycleInjector.builder() + .withModules(* arrayOf(OneMaxModule(), BaseModule())) + .build().createInjector() + + val ga = injector.getInstance( + Key.get(object : TypeLiteral>() {}) + ) + // Override selection strategy directly on the GA instance (no DI here) + ga.useSelectionStrategy(fixedSel) + return ga to injector +} + + + + diff --git a/docs/options.md b/docs/options.md index 4847bbc66e..88816d674e 100644 --- a/docs/options.md +++ b/docs/options.md @@ -68,7 +68,7 @@ There are 3 types of options: |`addPreDefinedTests`| __Boolean__. Add predefined tests at the end of the search. An example is a test to fetch the schema of RESTful APIs. *Default value*: `true`.| |`addTestComments`| __Boolean__. Add summary comments on each test. *Default value*: `true`.| |`advancedBlackBoxCoverage`| __Boolean__. Apply more advanced coverage criteria for black-box testing. This can result in larger generated test suites. *Default value*: `true`.| -|`algorithm`| __Enum__. The algorithm used to generate test cases. The default depends on whether black-box or white-box testing is done. *Valid values*: `DEFAULT, SMARTS, MIO, RANDOM, WTS, MOSA, RW, StandardGA, MonotonicGA, SteadyStateGA`. *Default value*: `DEFAULT`.| +|`algorithm`| __Enum__. The algorithm used to generate test cases. The default depends on whether black-box or white-box testing is done. *Valid values*: `DEFAULT, SMARTS, MIO, RANDOM, WTS, MOSA, RW, StandardGA, MonotonicGA, SteadyStateGA, BreederGA, CellularGA`. *Default value*: `DEFAULT`.| |`allowInvalidData`| __Boolean__. When generating data, allow in some cases to use invalid values on purpose. *Default value*: `true`.| |`appendToStatisticsFile`| __Boolean__. Whether should add to an existing statistics file, instead of replacing it. *Default value*: `false`.| |`archiveAfterMutationFile`| __String__. Specify a path to save archive after each mutation during search, only useful for debugging. *DEBUG option*. *Default value*: `archive.csv`.| @@ -246,7 +246,10 @@ There are 3 types of options: |`aiResponseClassifierWarmup`| __Int__. Number of training iterations required to update classifier parameters. For example, in the Gaussian model this affects mean and variance updates. For neural network (NN) models, the warm-up should typically be larger than 1000. *Default value*: `10`.| |`appendToTargetHeuristicsFile`| __Boolean__. Whether should add to an existing target heuristics file, instead of replacing it. It is only used when processFormat is TARGET_HEURISTIC. *Default value*: `false`.| |`bbProbabilityUseDataPool`| __Double__. Specify the probability of using the data pool when sampling test cases. This is for black-box (bb) mode. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.8`.| +|`breederParentsMin`| __Int__. Breeder GA: minimum number of individuals in parents pool after truncation. *Constraints*: `min=1.0`. *Default value*: `2`.| +|`breederTruncationFraction`| __Double__. Breeder GA: fraction of top individuals to keep in parents pool (truncation). *Constraints*: `min=0.0, max=1.0`. *Default value*: `0.5`.| |`callbackURLHostname`| __String__. HTTP callback verifier hostname. Default is set to 'localhost'. If the SUT is running inside a container (i.e., Docker), 'localhost' will refer to the container. This can be used to change the hostname. *Default value*: `localhost`.| +|`cgaNeighborhoodModel`| __Enum__. Cellular GA: neighborhood model (RING, L5, C9, C13). *Valid values*: `RING, L5, C9, C13`. *Default value*: `RING`.| |`classificationRepairThreshold`| __Double__. If using THRESHOLD for AI Classification Repair, specify its value. All classifications with probability equal or above such threshold value will be accepted. *Constraints*: `probability 0.0-1.0`. *Default value*: `0.8`.| |`discoveredInfoRewardedInFitness`| __Boolean__. If there is new discovered information from a test execution, reward it in the fitness function. *Default value*: `false`.| |`dockerLocalhost`| __Boolean__. Replace references to 'localhost' to point to the actual host machine. Only needed when running EvoMaster inside Docker. *Default value*: `false`.|