diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index 8158c19d91..68d965e874 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -2852,6 +2852,19 @@ class EMConfig { fun isEnabledAIModelForResponseClassification() = aiModelForResponseClassification != AIResponseClassifierModel.NONE + /** + * Source to build the final GA solution when evolving full test suites (not single tests). + * ARCHIVE: use current behavior (take tests from the archive). + * POPULATION: for GA algorithms, take the best suite (individual) from the final population. + */ + enum class GASolutionSource { ARCHIVE, POPULATION } + + /** + * Controls how GA algorithms produce the final solution. + * Default preserves current behavior. + */ + var gaSolutionSource: GASolutionSource = GASolutionSource.ARCHIVE + private var disabledOracleCodesList: List? = null fun getDisabledOracleCodesList(): List { diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/AbstractGeneticAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/AbstractGeneticAlgorithm.kt index 2412781721..a0b2027d59 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/algorithms/AbstractGeneticAlgorithm.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/AbstractGeneticAlgorithm.kt @@ -3,6 +3,16 @@ package org.evomaster.core.search.algorithms import org.evomaster.core.search.Individual import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual import org.evomaster.core.search.service.SearchAlgorithm +import org.evomaster.core.EMConfig +import org.evomaster.core.search.Solution +import com.google.inject.Inject +import org.evomaster.core.search.algorithms.strategy.suite.CrossoverOperator +import org.evomaster.core.search.algorithms.strategy.suite.MutationEvaluationOperator +import org.evomaster.core.search.algorithms.strategy.suite.DefaultMutationEvaluationOperator +import org.evomaster.core.search.algorithms.strategy.suite.SelectionStrategy +import org.evomaster.core.search.algorithms.strategy.suite.TournamentSelectionStrategy +import org.evomaster.core.search.algorithms.strategy.suite.DefaultCrossoverOperator +import org.evomaster.core.search.algorithms.observer.GAObserver /** * Abstract base class for implementing Genetic Algorithms (GAs) in EvoMaster. @@ -24,6 +34,29 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi /** The current population of evaluated individuals (test suites). */ protected val population: MutableList> = mutableListOf() + /** + * Frozen set of targets (coverage objectives) to score against during the current generation. + * Captured at the start of a generation and kept constant until the generation ends. + */ + protected var frozenTargets: Set = emptySet() + + protected var selectionStrategy: SelectionStrategy = TournamentSelectionStrategy() + + protected var crossoverOperator: CrossoverOperator = DefaultCrossoverOperator() + + protected var mutationOperator: MutationEvaluationOperator = DefaultMutationEvaluationOperator() + + /** Optional observers for GA events (test/telemetry). */ + protected val observers: MutableList> = mutableListOf() + + fun addObserver(observer: GAObserver) { + observers.add(observer) + } + + fun removeObserver(observer: GAObserver) { + observers.remove(observer) + } + /** * Called once before the search begins. Clears any old population and initializes a new one. */ @@ -59,7 +92,7 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi val nextPop: MutableList> = mutableListOf() if (config.elitesCount > 0 && population.isNotEmpty()) { - val sortedPopulation = population.sortedByDescending { it.calculateCombinedFitness() } + val sortedPopulation = population.sortedByDescending { score(it) } val elites = sortedPopulation.take(config.elitesCount) nextPop.addAll(elites) } @@ -78,30 +111,17 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi * This method modifies the individual in-place. */ protected fun mutate(wts: WtsEvalIndividual) { - val op = randomness.choose(listOf("del", "add", "mod")) - val n = wts.suite.size - - when (op) { - "del" -> if (n > 1) { - val i = randomness.nextInt(n) - wts.suite.removeAt(i) - } - - "add" -> if (n < config.maxSearchSuiteSize) { - ff.calculateCoverage(sampler.sample(), modifiedSpec = null)?.run { - archive.addIfNeeded(this) - wts.suite.add(this) - } - } - - "mod" -> { - val i = randomness.nextInt(n) - val ind = wts.suite[i] - - getMutatator().mutateAndSave(ind, archive) - ?.let { wts.suite[i] = it } - } - } + mutationOperator.mutateEvaluateAndArchive( + wts, + config, + randomness, + getMutatator(), + ff, + sampler, + archive + ) + // notify observers + observers.forEach { it.onMutation(wts) } } /** @@ -111,18 +131,18 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi * (bounded by the size of the smaller suite). */ protected fun xover(x: WtsEvalIndividual, y: WtsEvalIndividual) { - val nx = x.suite.size - val ny = y.suite.size - - val splitPoint = randomness.nextInt(Math.min(nx, ny)) - - (0..splitPoint).forEach { - val k = x.suite[it] - x.suite[it] = y.suite[it] - y.suite[it] = k - } + crossoverOperator.applyCrossover(x, y, randomness) + // notify observers + observers.forEach { it.onCrossover(x, y) } } + /** + * Allows tests or callers to override GA operators without DI. + */ + fun useSelectionStrategy(strategy: SelectionStrategy) { this.selectionStrategy = strategy } + fun useCrossoverOperator(operator: CrossoverOperator) { this.crossoverOperator = operator } + fun useMutationOperator(operator: MutationEvaluationOperator) { this.mutationOperator = operator } + /** * Selects one individual using tournament selection. * @@ -130,9 +150,9 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi * highest fitness among them is chosen. Falls back to random selection if needed. */ protected fun tournamentSelection(): WtsEvalIndividual { - val selectedIndividuals = randomness.choose(population, config.tournamentSize) - val bestIndividual = selectedIndividuals.maxByOrNull { it.calculateCombinedFitness() } - return bestIndividual ?: randomness.choose(population) + val sel = selectionStrategy.select(population, config.tournamentSize, randomness, ::score) + observers.forEach { it.onSelection(sel) } + return sel } /** @@ -141,13 +161,15 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi * Each element is generated by sampling and evaluated via the fitness function. * Stops early if the time budget is exceeded. */ - private fun sampleSuite(): WtsEvalIndividual { + protected fun sampleSuite(): WtsEvalIndividual { val n = 1 + randomness.nextInt(config.maxSearchSuiteSize) val suite = WtsEvalIndividual(mutableListOf()) for (i in 1..n) { ff.calculateCoverage(sampler.sample(), modifiedSpec = null)?.run { - archive.addIfNeeded(this) + if (config.gaSolutionSource != EMConfig.GASolutionSource.POPULATION) { + archive.addIfNeeded(this) + } suite.suite.add(this) } @@ -158,4 +180,57 @@ abstract class AbstractGeneticAlgorithm : SearchAlgorithm() where T : Indi return suite } + + /** + * Combined fitness of a suite computed only over [frozenTargets] when set; otherwise full combined fitness. + */ + protected fun score(w: WtsEvalIndividual): Double { + if (w.suite.isEmpty()) return 0.0 + + // Explicitly use full combined fitness when solution source is POPULATION + if (config.gaSolutionSource == EMConfig.GASolutionSource.POPULATION) { + return w.calculateCombinedFitness() + } + + if (frozenTargets.isEmpty()) { + return w.calculateCombinedFitness() + } + + val fv = w.suite.first().fitness.copy() + w.suite.forEach { ei -> fv.merge(ei.fitness) } + val view = fv.getViewOfData() + var sum = 0.0 + frozenTargets.forEach { t -> + val comp = view[t] + if (comp != null) sum += comp.score + } + return sum + } + + /** + * For GA algorithms, optionally build the final solution from the final population + * instead of the archive, controlled by config.gaSolutionSource. + */ + override fun buildSolution(): Solution { + return if (config.gaSolutionSource == EMConfig.GASolutionSource.POPULATION) { + val best = population.maxByOrNull { it.calculateCombinedFitness() } + val individuals = (best?.suite ?: mutableListOf()) + Solution( + individuals.toMutableList(), + config.outputFilePrefix, + config.outputFileSuffix, + org.evomaster.core.output.Termination.NONE, + listOf(), + listOf() + ) + } else { + super.buildSolution() + } + } + + /** + * Exposes a read-only view of the current population for observability/tests. + * Returns an immutable copy to prevent external mutations of internal state. + */ + fun getViewOfPopulation(): List> = population.toList() } diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithm.kt index 76805c8633..4355c10423 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithm.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithm.kt @@ -42,6 +42,8 @@ open class StandardGeneticAlgorithm : AbstractGeneticAlgorithm() where T : * Terminates early if the time budget is exceeded. */ override fun searchOnce() { + // Freeze objectives for this generation + frozenTargets = archive.notCoveredTargets() val n = config.populationSize // Generate the base of the next population (e.g., elitism or re-selection of fit individuals) diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/observer/GAObserver.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/observer/GAObserver.kt new file mode 100644 index 0000000000..87c47721ec --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/observer/GAObserver.kt @@ -0,0 +1,20 @@ +package org.evomaster.core.search.algorithms.observer + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual + +/** + * Observer for GA internal events used primarily for testing/telemetry. + * Default methods are no-ops so listeners can implement only what they need. + */ +interface GAObserver { + /** Called when one parent is selected. */ + fun onSelection(sel: WtsEvalIndividual) {} + /** Called immediately after crossover is applied to [x] and [y]. */ + fun onCrossover(x: WtsEvalIndividual, y: WtsEvalIndividual) {} + + /** Called immediately after mutation is applied to [wts]. */ + fun onMutation(wts: WtsEvalIndividual) {} +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/CrossoverOperator.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/CrossoverOperator.kt new file mode 100644 index 0000000000..95ab5d4b1f --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/CrossoverOperator.kt @@ -0,0 +1,11 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Randomness + +interface CrossoverOperator { + fun applyCrossover(x: WtsEvalIndividual, y: WtsEvalIndividual, randomness: Randomness) +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultCrossoverOperator.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultCrossoverOperator.kt new file mode 100644 index 0000000000..fb46f0bf1b --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultCrossoverOperator.kt @@ -0,0 +1,35 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Randomness +import kotlin.math.min + + + +/** + * Default crossover operator for GA test suites. + * + * Behavior: + * - Takes the two suites as input, named x and y. + * - Picks a split point uniformly at random in [0, min(len(x), len(y)) - 1]. + * - Swaps all elements from index 0 up to the split point (i ∈ [0, split]). + */ +class DefaultCrossoverOperator : CrossoverOperator { + override fun applyCrossover( + x: WtsEvalIndividual, + y: WtsEvalIndividual, + randomness: Randomness + ) { + val nx = x.suite.size + val ny = y.suite.size + val splitPoint = randomness.nextInt(min(nx, ny)) + (0..splitPoint).forEach { + val k = x.suite[it] + x.suite[it] = y.suite[it] + y.suite[it] = k + } + } +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultMutationEvaluationOperator.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultMutationEvaluationOperator.kt new file mode 100644 index 0000000000..992056b532 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/DefaultMutationEvaluationOperator.kt @@ -0,0 +1,70 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.EMConfig +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Archive +import org.evomaster.core.search.service.FitnessFunction +import org.evomaster.core.search.service.Randomness +import org.evomaster.core.search.service.Sampler +import org.evomaster.core.search.service.mutator.Mutator + +/** + * Default mutation operator acting at the test suite level for GA. + * + * Behavior: + * - Randomly applies one of: "del", "add", "mod" (selected randomly) + * - del: if size > 1, remove a random test from the suite. + * - add: if size < maxSearchSuiteSize, sample + evaluate; add new test to suite (and archive if needed). + * - mod: mutate one random test in the suite; re-evaluate (or mutateAndSave if GASolutionSource = ARCHIVE). + */ +class DefaultMutationEvaluationOperator : MutationEvaluationOperator { + companion object { + private const val OP_DELETE = "del" + private const val OP_ADD = "add" + private const val OP_MOD = "mod" + } + + override fun mutateEvaluateAndArchive( + wts: WtsEvalIndividual, + config: EMConfig, + randomness: Randomness, + mutator: Mutator, + ff: FitnessFunction, + sampler: Sampler, + archive: Archive + ) { + val op = randomness.choose(listOf(OP_DELETE, OP_ADD, OP_MOD)) + val n = wts.suite.size + + when (op) { + OP_DELETE -> if (n > 1) { + val i = randomness.nextInt(n) + wts.suite.removeAt(i) + } + + OP_ADD -> if (n < config.maxSearchSuiteSize) { + ff.calculateCoverage(sampler.sample(), modifiedSpec = null)?.run { + if (config.gaSolutionSource == EMConfig.GASolutionSource.ARCHIVE) { + archive.addIfNeeded(this) + } + wts.suite.add(this) + } + } + + OP_MOD -> { + val i = randomness.nextInt(n) + val ind = wts.suite[i] + + if (config.gaSolutionSource == EMConfig.GASolutionSource.ARCHIVE) { + mutator.mutateAndSave(ind, archive)?.let { wts.suite[i] = it } + } else { + val mutated = mutator.mutate(ind) + ff.calculateCoverage(mutated, modifiedSpec = null)?.let { wts.suite[i] = it } + } + } + } + } +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/MutationEvaluationOperator.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/MutationEvaluationOperator.kt new file mode 100644 index 0000000000..78a2eaa7e8 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/MutationEvaluationOperator.kt @@ -0,0 +1,27 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.EMConfig +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Archive +import org.evomaster.core.search.service.FitnessFunction +import org.evomaster.core.search.service.Randomness +import org.evomaster.core.search.service.Sampler +import org.evomaster.core.search.service.mutator.Mutator + +interface MutationEvaluationOperator { + /** + * Applies a single mutation action at the test suite level. + */ + fun mutateEvaluateAndArchive( + wts: WtsEvalIndividual, + config: EMConfig, + randomness: Randomness, + mutator: Mutator, + ff: FitnessFunction, + sampler: Sampler, + archive: Archive + ) +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/SelectionStrategy.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/SelectionStrategy.kt new file mode 100644 index 0000000000..95ca48e482 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/SelectionStrategy.kt @@ -0,0 +1,17 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Randomness + +interface SelectionStrategy { + fun select( + population: List>, + tournamentSize: Int, + randomness: Randomness, + score: (WtsEvalIndividual) -> Double + ): WtsEvalIndividual +} + + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/TournamentSelectionStrategy.kt b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/TournamentSelectionStrategy.kt new file mode 100644 index 0000000000..9dfd3e7620 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/search/algorithms/strategy/suite/TournamentSelectionStrategy.kt @@ -0,0 +1,31 @@ +package org.evomaster.core.search.algorithms.strategy.suite + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Randomness + +/** + * Tournament selection strategy for GA suites. + * + * Behavior: + * - Randomly samples [tournamentSize] distinct individuals from [population]. + * - Computes their fitness with [score] and returns the one with the highest value. + */ +class TournamentSelectionStrategy : SelectionStrategy { + override fun select( + population: List>, + tournamentSize: Int, + randomness: Randomness, + score: (WtsEvalIndividual) -> Double + ): WtsEvalIndividual { + if (population.isEmpty()) { + throw IllegalArgumentException("Tournament selection requires a non-empty population") + } + + val k = minOf(tournamentSize.coerceAtLeast(1), population.size) + val selected = randomness.choose(population, k) + return selected.maxBy { score(it) } + } +} + + diff --git a/core/src/main/kotlin/org/evomaster/core/search/service/SearchAlgorithm.kt b/core/src/main/kotlin/org/evomaster/core/search/service/SearchAlgorithm.kt index 2ff3a49d5f..695dbd693a 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/service/SearchAlgorithm.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/service/SearchAlgorithm.kt @@ -89,6 +89,10 @@ abstract class SearchAlgorithm where T : Individual { handleAfterSearch() + return buildSolution() + } + + open fun buildSolution(): Solution { return archive.extractSolution() } diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithmTest.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithmTest.kt index 9fc3ec298c..61daf3e74b 100644 --- a/core/src/test/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithmTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/StandardGeneticAlgorithmTest.kt @@ -11,18 +11,27 @@ 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.service.ExecutionPhaseController +import org.evomaster.core.search.service.Randomness import org.junit.jupiter.api.Test +import org.junit.jupiter.api.BeforeEach +import org.evomaster.core.search.service.ExecutionPhaseController import org.junit.jupiter.api.Assertions.* +import org.evomaster.core.search.algorithms.strategy.FixedSelectionStrategy +import org.evomaster.core.search.algorithms.observer.GARecorder class StandardGeneticAlgorithmTest { - val injector: Injector = LifecycleInjector.builder() - .withModules(* arrayOf(OneMaxModule(), BaseModule())) - .build().createInjector() + private lateinit var injector: Injector - // To verify if the Standard GA can find the optimal solution for the OneMax problem + @BeforeEach + fun setUp() { + injector = LifecycleInjector.builder() + .withModules(* arrayOf(OneMaxModule(), BaseModule())) + .build().createInjector() + } + + // Verifies that the Standard GA can find the optimal solution for the OneMax problem @Test fun testStandardGeneticAlgorithm() { TestUtils.handleFlaky { @@ -37,14 +46,188 @@ class StandardGeneticAlgorithmTest { val epc = injector.getInstance(ExecutionPhaseController::class.java) epc.startSearch() - val solution = standardGeneticAlgorithm.search() - epc.finishSearch() - assertTrue(solution.individuals.size == 1) assertEquals(OneMaxSampler.DEFAULT_N.toDouble(), solution.overall.computeFitnessScore(), 0.001) } } + + // Verifies that searchOnce() creates the next generation as expected + @Test + fun testNextGenerationIsElitesPlusSelectedOffspring() { + TestUtils.handleFlaky { + + // Create GA with Fixed Selection + val fixedSel = FixedSelectionStrategy() + val (ga, localInjector) = createGAWithSelection(fixedSel) + + // Add Observer + + val rec = GARecorder() + ga.addObserver(rec) + + // Set Config + + val config = localInjector.getInstance(EMConfig::class.java) + localInjector.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 + + ga.setupBeforeSearch() + + val pop = ga.getViewOfPopulation() + + // Expected Elites (top-2 by combined fitness) + val expectedElites = pop.sortedByDescending { it.calculateCombinedFitness() }.take(2) + + // Non Elites + val expectedNonElites = pop.filter { it !in expectedElites } + + // Configure selection order after setup + fixedSel.setOrder(listOf(expectedNonElites[0], expectedNonElites[1])) + + ga.searchOnce() + + val nextPop = ga.getViewOfPopulation() + + // population size preserved + assertEquals(config.populationSize, nextPop.size) + + // selection is called twice (via observer) + assertEquals(2, rec.selections.size) + + // Check that elites are present in nextPop + assertTrue(nextPop.any { it === expectedElites[0] }) + assertTrue(nextPop.any { it === expectedElites[1] }) + + // crossover was called with 2 offspring (captured by observer) + assertEquals(1, rec.xoCalls.size) + val (o1, o2) = rec.xoCalls[0] + assertTrue(nextPop.any { it === o1 }) + assertTrue(nextPop.any { it === o2 }) + + // mutation happened twice on the offspring (captured by observer) + assertEquals(2, rec.mutated.size) + assertTrue(rec.mutated.any { it === o1 }) + assertTrue(rec.mutated.any { it === o2 }) + + } + } + + // Tests Edge Case: CrossoverProbability=0 + + @Test + fun testNoCrossoverWhenProbabilityZero() { + 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.elitesCount = 2 + 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() + + val pop = ga.getViewOfPopulation() + val expectedElites = pop.sortedByDescending { it.calculateCombinedFitness() }.take(2) + val expectedNonElites = pop.filter { it !in expectedElites } + + fixedSel.setOrder(listOf(expectedNonElites[0], expectedNonElites[1])) + + ga.searchOnce() + + val nextPop = ga.getViewOfPopulation() + + assertEquals(config.populationSize, nextPop.size) + assertEquals(2, rec.selections.size) + assertTrue(nextPop.any { it === expectedElites[0] }) + assertTrue(nextPop.any { it === expectedElites[1] }) + + // crossover disabled + assertEquals(0, rec.xoCalls.size) + // mutation still applied twice + assertEquals(2, rec.mutated.size) + + } + } + + // Tests Edge Case: MutationProbability=0 + @Test + fun testNoMutationWhenProbabilityZero() { + 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.elitesCount = 2 + 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 pop = ga.getViewOfPopulation() + val expectedElites = pop.sortedByDescending { it.calculateCombinedFitness() }.take(2) + val expectedNonElites = pop.filter { it !in expectedElites } + + fixedSel.setOrder(listOf(expectedNonElites[0], expectedNonElites[1])) + + ga.searchOnce() + + val nextPop = ga.getViewOfPopulation() + + assertEquals(config.populationSize, nextPop.size) + assertEquals(2, rec.selections.size) + assertTrue(nextPop.any { it === expectedElites[0] }) + assertTrue(nextPop.any { it === expectedElites[1] }) + + // crossover forced + assertEquals(1, rec.xoCalls.size) + // mutation disabled + assertEquals(0, rec.mutated.size) + } + } +} + +// --- 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 } \ No newline at end of file diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/observer/GARecorder.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/observer/GARecorder.kt new file mode 100644 index 0000000000..7bf746ffe0 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/observer/GARecorder.kt @@ -0,0 +1,25 @@ +package org.evomaster.core.search.algorithms.observer + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual + +/** Test utility observer that records GA events. */ +class GARecorder : GAObserver { + val selections = mutableListOf>() + val xoCalls = mutableListOf, WtsEvalIndividual>>() + val mutated = mutableListOf>() + + override fun onSelection(sel: WtsEvalIndividual) { + selections.add(sel) + } + + override fun onCrossover(x: WtsEvalIndividual, y: WtsEvalIndividual) { + xoCalls.add(x to y) + } + + override fun onMutation(wts: WtsEvalIndividual) { + mutated.add(wts) + } +} + + diff --git a/core/src/test/kotlin/org/evomaster/core/search/algorithms/strategy/FixedSelectionStrategy.kt b/core/src/test/kotlin/org/evomaster/core/search/algorithms/strategy/FixedSelectionStrategy.kt new file mode 100644 index 0000000000..c99e17bf9b --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/algorithms/strategy/FixedSelectionStrategy.kt @@ -0,0 +1,36 @@ +package org.evomaster.core.search.algorithms.strategy + +import org.evomaster.core.search.Individual +import org.evomaster.core.search.algorithms.wts.WtsEvalIndividual +import org.evomaster.core.search.service.Randomness +import org.evomaster.core.search.algorithms.strategy.suite.SelectionStrategy + +/** + * Fixed-order selection strategy for tests. Returns a predefined FIFO sequence + * of individuals provided via [setOrder]. Useful to deterministically control + * which parents are selected during tests. + */ +class FixedSelectionStrategy : SelectionStrategy { + private val queue = ArrayDeque>() + private var callCount: Int = 0 + + fun setOrder(order: List>) { + queue.clear() + queue.addAll(order) + } + + fun getCallCount(): Int = callCount + + @Suppress("UNCHECKED_CAST") + override fun select( + population: List>, + tournamentSize: Int, + randomness: Randomness, + score: (WtsEvalIndividual) -> Double + ): WtsEvalIndividual { + callCount++ + return queue.removeFirst() as WtsEvalIndividual + } +} + +