From 1d4d223af27058857ec22bd9ccb41dcf09eb3042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 08:07:54 -0700 Subject: [PATCH] Add MinConflictsStrategy Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0175jMLxrP7wZ3vfj6UfeDZx --- .../minconflicts/MinConflictsConfig.scala | 30 ++++++ .../minconflicts/MinConflictsStrategy.scala | 91 ++++++++++++++++ .../MinConflictsStrategySuite.scala | 101 ++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsConfig.scala create mode 100644 scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategy.scala create mode 100644 scala-test/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategySuite.scala diff --git a/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsConfig.scala b/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsConfig.scala new file mode 100644 index 0000000..d828085 --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsConfig.scala @@ -0,0 +1,30 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.minconflicts + +/** + * Tunables for [[MinConflictsStrategy]]. + * + * Every step picks one position that is in conflict, looks at every move of that position, and takes the move that + * leaves the fewest conflicts. + * + * @param maxSteps number of moves taken. + * @param noiseProbability chance that a step takes a random move of the position instead of the best one. + * A little noise (0.02 to 0.1) lets the search leave a local optimum. + * @param randomTieBreaking choose at random among moves that are equally good; if false the first is taken. + * @param restartAfterStagnantSteps after this many steps without a new best solution, jump to a random solution (the + * best is kept); 0 disables. + */ +final case class MinConflictsConfig( + maxSteps: Int = 100000, + noiseProbability: Double = 0.02, + randomTieBreaking: Boolean = true, + restartAfterStagnantSteps: Int = 2000 +) { + require(maxSteps >= 0, s"maxSteps must not be negative, got $maxSteps") + require(noiseProbability >= 0.0 && noiseProbability <= 1.0, s"noiseProbability must be in [0, 1], got $noiseProbability") + require(restartAfterStagnantSteps >= 0, s"restartAfterStagnantSteps must not be negative, got $restartAfterStagnantSteps") +} + +object MinConflictsConfig { + val Default: MinConflictsConfig = MinConflictsConfig() +} diff --git a/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategy.scala b/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategy.scala new file mode 100644 index 0000000..784024a --- /dev/null +++ b/scala-source/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategy.scala @@ -0,0 +1,91 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.minconflicts + +import com.barrybecker4.math.MathUtil +import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededException, Optimizee} +import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness} +import com.barrybecker4.optimization.strategy.OptimizationStrategy + +import scala.util.Random + +/** + * The min-conflicts heuristic for constraint problems whose fitness is the number of broken constraints (so that 0 is + * a solution). Each step takes a position at random from [[Optimizee.conflictedPositions]], scores every move of + * that position ([[com.barrybecker4.optimization.parameter.ParameterArray.movesAt]]), and takes the one with the fewest + * conflicts left, or, with the noise probability, a random one. Unlike hill climbing it moves even when no move improves, + * which walks across plateaus. + * + * It is only as good as the conflict information. When the optimizee does not report conflicted positions + * (the default), the position is chosen at random from all of them and the search is a randomized greedy search that + * does far less than it can. The array must have local moves ([[com.barrybecker4.optimization.parameter.ParameterArray.supportsLocalMoves]]). + * + * Works with absolute fitness only. When the optimizee is wrapped with an evaluation budget the best solution found + * so far is returned when the budget is used up. + * + * @param optimizee the thing to be optimized. + * @param rnd chooses positions, noise and ties. + */ +class MinConflictsStrategy(optimizee: Optimizee, rnd: Random = MathUtil.RANDOM) extends OptimizationStrategy(optimizee) { + + private var config: MinConflictsConfig = MinConflictsConfig.Default + + def getConfig: MinConflictsConfig = config + + def setConfig(config: MinConflictsConfig): Unit = { this.config = config } + + override def doOptimization(params: ParameterArray, fitnessRange: Double): ParameterArrayWithFitness = { + require(!optimizee.evaluateByComparison, "Min-conflicts needs absolute fitness, not comparison") + require(params.supportsLocalMoves, "Min-conflicts needs an array with local moves") + var best = ParameterArrayWithFitness(params, Double.PositiveInfinity) + try best = search(params) + catch { case _: EvaluationBudgetExceededException => best = bestSoFar.getOrElse(best) } + best + } + + private def search(start: ParameterArray): ParameterArrayWithFitness = { + var current = scored(start) + var best = current + notifyOfChange(best) + var stagnant = 0 + var step = 0 + while (step < config.maxSteps && !isOptimalFitnessReached(best)) { + step += 1 + current = takeStep(current) + if (current.fitness < best.fitness) { best = current; stagnant = 0; notifyOfChange(best) } else stagnant += 1 + if (shouldRestart(stagnant)) { current = scored(best.pa.getRandomSample); stagnant = 0 } + trace("step " + step + " current = " + current.fitness + " best = " + best.fitness) + } + best + } + + private def shouldRestart(stagnantSteps: Int): Boolean = + config.restartAfterStagnantSteps > 0 && stagnantSteps >= config.restartAfterStagnantSteps + + private def scored(array: ParameterArray): ParameterArrayWithFitness = + ParameterArrayWithFitness(array, cleaned(optimizee.evaluateFitness(array))) + + /** A NaN must never look like an improvement. */ + private def cleaned(fitness: Double): Double = if (fitness.isNaN) Double.PositiveInfinity else fitness + + /** A conflicted position, or any position if the optimizee cannot say which are. */ + private def pickPosition(current: ParameterArray): Int = { + val conflicted = optimizee.conflictedPositions(current) + if (conflicted.nonEmpty) conflicted(rnd.nextInt(conflicted.size)) else rnd.nextInt(current.size) + } + + private def takeStep(current: ParameterArrayWithFitness): ParameterArrayWithFitness = { + val moves = current.pa.movesAt(pickPosition(current.pa)).toIndexedSeq + if (moves.isEmpty) current + else { + val fitnesses = moves.map(move => cleaned(optimizee.evaluateMove(current.pa, current.fitness, move))) + val chosen = if (rnd.nextDouble() < config.noiseProbability) rnd.nextInt(moves.size) else indexOfBest(fitnesses) + ParameterArrayWithFitness(current.pa.applyMove(moves(chosen)), fitnesses(chosen)) + } + } + + /** The index of the lowest fitness; a tie goes to a random one of them, or the first. */ + private def indexOfBest(fitnesses: IndexedSeq[Double]): Int = { + val ties = fitnesses.indices.filter(i => fitnesses(i) == fitnesses.min) + if (config.randomTieBreaking) ties(rnd.nextInt(ties.size)) else ties.head + } +} diff --git a/scala-test/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategySuite.scala b/scala-test/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategySuite.scala new file mode 100644 index 0000000..d8fa731 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/strategy/minconflicts/MinConflictsStrategySuite.scala @@ -0,0 +1,101 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.strategy.minconflicts + +import com.barrybecker4.optimization.benchmark.{GraphColoringProblem, NQueensProblem} +import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, Optimizee} +import com.barrybecker4.optimization.parameter.{Move, ParameterArray, PuzzleLikePath, PermutedParameterArray, Swap} +import org.scalatest.funsuite.AnyFunSuite + +import scala.collection.mutable.ArrayBuffer +import scala.util.Random + +class MinConflictsStrategySuite extends AnyFunSuite { + + private def strategyFor(problem: Optimizee, config: MinConflictsConfig, seed: Long = 1) = { + val strategy = new MinConflictsStrategy(problem, new Random(seed)) + strategy.setConfig(config) + strategy + } + + test("it solves a larger N-queens problem quickly") { + val problem = new NQueensProblem(100, 1) + val budget = new BudgetedOptimizee(problem, 2000000) + val result = strategyFor(budget, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0) + assertResult(0.0)(result.fitness) + assert(!budget.wasBudgetExhausted) + } + + test("it solves a small graph coloring problem") { + val problem = new GraphColoringProblem(60, 4, 5.0, 2) + val result = strategyFor(problem, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0) + assertResult(0.0)(result.fitness) + } + + test("every move that is scored belongs to a queen that is in conflict") { + var scoredMoves = 0 + var outsideConflicts = 0 + val recording = new NQueensProblem(40, 3) { + override def evaluateMove(c: ParameterArray, f: Double, m: Move): Double = { + m match { + case Swap(position, _) => + scoredMoves += 1 + if (f > 0 && !conflictedPositions(c).contains(position)) outsideConflicts += 1 + case _ => + } + super.evaluateMove(c, f, m) + } + } + strategyFor(recording, MinConflictsConfig(maxSteps = 30, noiseProbability = 0.0, restartAfterStagnantSteps = 0)) + .doOptimization(recording.getInitialGuess, 1.0) + assert(scoredMoves > 0) + assertResult(0)(outsideConflicts) + } + + test("a run with no noise repeats for the same seed") { + def run() = { + val problem = new NQueensProblem(52, 3) + strategyFor(problem, MinConflictsConfig(maxSteps = 25, noiseProbability = 0.0), seed = 4) + .doOptimization(problem.getInitialGuess, 1.0).fitness + } + assertResult(run())(run()) + } + + test("each step scores every move of the position, which is the size less one for a permutation") { + val problem = new NQueensProblem(40, 3) + val budget = new BudgetedOptimizee(problem, Int.MaxValue) + strategyFor(budget, MinConflictsConfig(maxSteps = 5, restartAfterStagnantSteps = 0)).doOptimization(problem.getInitialGuess, 1.0) + assertResult(1 + 5 * 39)(budget.evaluationsUsed) + } + + test("when the problem reports no conflicts it still runs, with a random position") { + val problem = new NQueensProblem(30, 3) { override def conflictedPositions(c: ParameterArray): Seq[Int] = Seq.empty } + val start = problem.getInitialGuess + val result = strategyFor(problem, MinConflictsConfig(maxSteps = 200)).doOptimization(start, 1.0) + assert(result.fitness <= problem.evaluateFitness(start)) + } + + test("when the budget is used up the best so far is returned") { + val problem = new NQueensProblem(60, 3) + val budget = new BudgetedOptimizee(problem, 300) + val result = strategyFor(budget, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0) + assert(budget.wasBudgetExhausted) + assertResult(result.fitness)(problem.evaluateFitness(result.pa)) + } + + test("an array without local moves is refused") { + val problem = new NQueensProblem(30, 3) + val plain = new PuzzleLikePath(problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params, "path") + intercept[IllegalArgumentException](strategyFor(problem, MinConflictsConfig()).doOptimization(plain, 1.0)) + } + + test("comparison of fitness is refused") { + val problem = new NQueensProblem(30, 1) { override def evaluateByComparison: Boolean = true } + intercept[IllegalArgumentException](strategyFor(problem, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0)) + } + + test("the configuration refuses values that make no sense") { + intercept[IllegalArgumentException](MinConflictsConfig(noiseProbability = 1.5)) + intercept[IllegalArgumentException](MinConflictsConfig(maxSteps = -1)) + intercept[IllegalArgumentException](MinConflictsConfig(restartAfterStagnantSteps = -1)) + } +}