package approach.simulation import java.util.concurrent.TimeUnit import kotlin.math.max import kotlin.random.Random import approach.domain.KromaiaFormat import util.IO import util.Math.median import util.writeLn class EAlgorithm( encoding: KromaiaFormat, private val name: String, private val oracle: Configuration, private val randomSearch: Boolean = false ) { private val timestamp = System.currentTimeMillis() / 1000L private val log = mutableListOf() private val population = mutableListOf() private var maxPopulationSize = population.size private var generationCounter = 1 val confusionMatrixRow: Array by lazy { // The traces of the simulations are sorted by how bad they have gone or, in other words, by asceding fitness. // The EMoSim approach takes the first individual from the simulation population (i.e., the individual with the worst fitness). // The Random Search (RS) approach takes a random individual from the simulation population. val trace = if (!randomSearch) (population.first() as IndividualBossConfig).trace else (population[(0 until population.size).random()] as IndividualBossConfig).trace val distribution = trace.groupingBy { it }.eachCount() val median = median(distribution.values.toMutableList()) // EMoSim just takes the relevant model fragment from the oracle. RS takes the whole model. val fragmentIndices = if (!randomSearch) mutableSetOf() else // encoding.hu.indices.asSequence().shuffled().take((1 .. CONFIGURATION_ROW_LENGTH).random()).toSortedSet() oracle.indices.toSortedSet() if (!randomSearch) oracle.toCharArray().forEachIndexed { i, _ -> if ((i < oracle.length - 1 && oracle[i + 1] == '1') || oracle[i] == '1' || (i > 0 && oracle[i - 1] == '1') ) { fragmentIndices.add(i) } } // If repetition of element (hull visited) in trace is equal or above median value, // then consider as present in the fragment model. val threshold = if (!randomSearch) if (median > 30) median else distribution.values.random() else Random(System.nanoTime()).nextInt(distribution.values.random()) var truePositives = 0 var falsePositives = 0 var falseNegatives = 0 var trueNegatives = 0 for (i in fragmentIndices) { if (trace.count { it == i } > threshold) { if (oracle[i] == '1') { truePositives++ } else { falsePositives++ } } else { if (oracle[i] == '1') { falseNegatives++ } else { trueNegatives++ } } } arrayOf(truePositives, falsePositives, falseNegatives, trueNegatives) } init { setupPopulation(encoding) } fun setBounds(maxPopulationSize: Int): EAlgorithm { this.maxPopulationSize = maxPopulationSize return this } private var timeLimitHours = 0 private var timeLimitMinutes = 0 private var timeLimitSeconds = 0 private var timeLimit = 0L fun setTimeLimit(hours: Int, minutes: Int, seconds: Int): EAlgorithm { this.timeLimitHours = hours this.timeLimitMinutes = minutes this.timeLimitSeconds = seconds this.timeLimit = (timeLimitHours * 3600 + timeLimitMinutes * 60 + timeLimitSeconds).toLong() return this } fun runSimulation(evo: Boolean = true, verbose: Boolean = false): EAlgorithm { /** * Simulation loop. */ val startTime = System.nanoTime() var numberOfRuns = 0 var individual = population.first() as IndividualBossConfig val averageDuelStats = IndividualBossConfig.DuelStats() while (TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime) < timeLimit) { if (population.count { (it as IndividualBossConfig).trace.isNotEmpty() } == maxPopulationSize) break // Evaluation. evaluatePopulation() // Ranking. individual = sortPopulation(desc = false) as IndividualBossConfig // Evolution. if (evo && population.size < maxPopulationSize) { evolvePopulation() // Print best results (after run). if (verbose) { log.add("Generation: $generationCounter") log.add("Population after run: ${population.size}") log.add("Best performing individual: ${individual.name} [${individual.configuration}]\n") } } averageDuelStats.Q_completion += individual.duelStats.Q_completion averageDuelStats.Q_duration += individual.duelStats.Q_duration averageDuelStats.Q_uncertainty += individual.duelStats.Q_uncertainty averageDuelStats.Q_killerMoves += individual.duelStats.Q_killerMoves averageDuelStats.Q_permanence += individual.duelStats.Q_permanence averageDuelStats.Q_leadChange += individual.duelStats.Q_leadChange averageDuelStats.Q_overall += individual.duelStats.Q_overall ++numberOfRuns } /** * Print best results (after every run). */ if (verbose) { log.add(" ════ Settings / Stats ════ ") log.add("┌─ Time running (s): ${TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startTime)}") log.add("│─ Number of duels: ${individual.duelSettings.numberOfDuels}") log.add("└─ Number of player wins: ${individual.duelStats.numberOfPlayerWins}") log.add("") log.add(" ════ Fitness ════ ") log.add("┌─ F_PlayerWinPercentage: ${individual.duelStats.F_playerWinPercentage}") log.add("│─ F_PlayerWinLifePercentage: ${individual.duelStats.F_playerWinLifePercentage}") log.add("│─ F_PlayerWinLifeGapPercentage: ${individual.duelStats.F_playerWinLifeGapPercentage}") log.add("└─ F_Overall: ${individual.duelStats.F_overall}") log.add("") } /** * Print average results (after every run). */ if (verbose) { log.add(" ════ Ranking ════ ") log.add(" # Individual") population.forEachIndexed { i, p -> log.add("${(i + 1).toString().padStart(3, ' ')}. ${(p as IndividualBossConfig).name}") log.add(" │─ Configuration: ${p.configuration}") log.add(" └─ Trace: ${p.trace}") } log.add("") } // Normalize average solution quality. averageDuelStats.Q_completion /= numberOfRuns averageDuelStats.Q_duration /= numberOfRuns averageDuelStats.Q_uncertainty /= numberOfRuns averageDuelStats.Q_killerMoves /= numberOfRuns averageDuelStats.Q_permanence /= numberOfRuns averageDuelStats.Q_leadChange /= numberOfRuns averageDuelStats.Q_overall /= numberOfRuns if (verbose) { log.add(" ════ Quality ════ ") log.add("┌─ Q_Completion: ${averageDuelStats.Q_completion} ") log.add("│─ Q_Duration: ${averageDuelStats.Q_duration} ") log.add("│─ Q_Uncertainty: ${averageDuelStats.Q_uncertainty}") log.add("│─ Q_KillerMoves: ${averageDuelStats.Q_killerMoves}") log.add("│─ Q_Permanence: ${averageDuelStats.Q_permanence} ") log.add("│─ Q_LeadChange: ${averageDuelStats.Q_leadChange} ") log.add("└─ Q_Overall: ${averageDuelStats.Q_overall} ") log.add("") } if (verbose) printLogTraces("out/${name}_${timestamp}.log") /** * Generate CSV file with individuals duel quality stats. */ if (verbose) generateReport("out/${name}_${timestamp}.csv") return this } private fun setupPopulation(encoding: KromaiaFormat) { population.add(IndividualBossConfig(encoding)) } private fun evaluatePopulation() { population.forEach { individual -> individual.updateFitness() } } @Suppress("SameParameterValue") private fun sortPopulation(desc: Boolean = true): Individual { if (desc) population.sortByDescending { individual -> individual.fitness } else population.sortBy { individual -> individual.fitness } // Return the first individual, which corresponds to the individual with the highest fitness. return population.first() } private fun evolvePopulation() { var generated = false val targetPopulationSize = max(2, (0.1 * population.size).toInt()) for (i in 0 until targetPopulationSize) { for (j in i + 1 until targetPopulationSize) { population.add(population[i].crossover(population[if (j >= population.size) i else j]).mutate()) generated = true } } if (generated) ++generationCounter } private fun printLogTraces(path: String) { IO.mkdirsIfNotExist(path).bufferedWriter().use { out -> log.forEach { line -> out.writeLn(line) } } } private fun generateReport(path: String) { IO.mkdirsIfNotExist(path).bufferedWriter().use { out -> out.writeLn(";Q_Completion;Q_Duration;Q_Uncertainty;Q_KillerMoves;Q_Permanence;Q_LeadChange;Q_Overall;") population.filterIsInstance().forEach { individual -> out.writeLn( "${individual.name};" + "${individual.duelStats.Q_completion};" + "${individual.duelStats.Q_duration};" + "${individual.duelStats.Q_uncertainty};" + "${individual.duelStats.Q_killerMoves};" + "${individual.duelStats.Q_permanence};" + "${individual.duelStats.Q_leadChange};" + "${individual.duelStats.Q_overall};" ) } } } }