From d654cfb76af07d9f543a9ae8da8b8f166e386214 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:56:42 -0700 Subject: [PATCH] Add local moves, attributes and conflictedPositions hooks for constraint strategies Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0175jMLxrP7wZ3vfj6UfeDZx --- docs/new-strategies-plan.md | 4 +- .../optimizee/BudgetedOptimizee.scala | 2 + .../optimizee/DeltaCheckingOptimizee.scala | 1 + .../optimization/optimizee/Optimizee.scala | 10 ++ .../optimization/parameter/Move.scala | 9 ++ .../parameter/NumericParameterArray.scala | 41 ++++++ .../parameter/ParameterArray.scala | 35 +++++ .../parameter/PermutedParameterArray.scala | 35 +++++ .../benchmark/AllIntervalProblem.scala | 11 +- .../benchmark/ConstraintHooksSuite.scala | 74 +++++++++++ .../benchmark/GraphColoringProblem.scala | 19 ++- .../benchmark/LimitedOptimizee.scala | 1 + .../benchmark/NQueensProblem.scala | 11 ++ .../optimizee/OptimizeeHooksSuite.scala | 14 ++ .../parameter/LocalMovesSuite.scala | 124 ++++++++++++++++++ 15 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 scala-test/com/barrybecker4/optimization/benchmark/ConstraintHooksSuite.scala create mode 100644 scala-test/com/barrybecker4/optimization/parameter/LocalMovesSuite.scala diff --git a/docs/new-strategies-plan.md b/docs/new-strategies-plan.md index 5c7c319..7f51574 100644 --- a/docs/new-strategies-plan.md +++ b/docs/new-strategies-plan.md @@ -119,13 +119,13 @@ an optimization problem, so neither adds information. ### Phase B: shared pieces (additive, no behavior change) -- `Move.attributes` and a `SetValue(index, value)` move. +- `SetValue(index, value)` and `Placement(position, value)` moves. Instead of `Move.attributes`, `ParameterArray.createdAttributes(move)` and `destroyedAttributes(move)` return the placements a move makes and undoes (Phase B). - Moves for `NumericParameterArray` when every parameter is an integer (and for boolean parameters), with `proposeMove`, `applyMove` and `supportsMoves`, consistent with `getRandomNeighbor`: applying the proposed move gives the same array as `getRandomNeighbor` for the same random numbers (the rule used for permutations). - `Optimizee.conflictedPositions` with a default of empty, forwarded by the wrappers (`BudgetedOptimizee`, `DeltaCheckingOptimizee`, `LimitedOptimizee`) as `repair` and `proposeGuidedMove` are. -- `ParameterArray.enumerateMoves(limit)` for permutations, with a default of "unsupported". +- `ParameterArray.supportsLocalMoves`, `movesAt(position)` and `proposeLocalMove(rnd)` (swaps for permutations, `SetValue` for integer arrays; capped at 64 sampled values per position), replacing `enumerateMoves`. - `DeltaCheckingOptimizee` already checks any move against a full evaluation, so the new move types are covered by it. ### Phase C: adaptive restarts diff --git a/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala index bf674f2..f742b15 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/BudgetedOptimizee.scala @@ -56,6 +56,8 @@ class BudgetedOptimizee(delegate: Optimizee, val maxEvaluations: Int) extends Op override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = delegate.proposeGuidedMove(current, radius, rnd) + override def conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current) + private def bump(): Unit = synchronized { if (count >= maxEvaluations) { budgetExhausted = true diff --git a/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala index d197668..bc41170 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/DeltaCheckingOptimizee.scala @@ -26,6 +26,7 @@ class DeltaCheckingOptimizee(delegate: Optimizee, tolerance: Double = 1e-9) exte override def repair(candidate: ParameterArray): ParameterArray = delegate.repair(candidate) override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = delegate.proposeGuidedMove(current, radius, rnd) + override def conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current) override def evaluateMove(current: ParameterArray, currentFitness: Double, move: Move): Double = { checkCurrentFitness(current, currentFitness, move) diff --git a/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala b/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala index 4cb078a..2f1c6f0 100644 --- a/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala +++ b/scala-source/com/barrybecker4/optimization/optimizee/Optimizee.scala @@ -80,6 +80,16 @@ trait Optimizee { */ def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = None + /** + * The positions of the array (indices of its parameters) that take part in a violated constraint: the queens that + * are attacked, the nodes with a neighbor of the same color. Min-conflicts picks the position to change from these, + * and so does any strategy that wants to focus on what is wrong. An empty result means "not known", not "nothing is + * wrong", so a strategy then picks a position at random, which is the default. Only used for absolute fitness. + * + * @param current the array to look at + */ + def conflictedPositions(current: ParameterArray): Seq[Int] = Seq.empty + /** if non-0, then we terminate when the fitness reaches this value. * @return the best (largest) expected fitness value. */ diff --git a/scala-source/com/barrybecker4/optimization/parameter/Move.scala b/scala-source/com/barrybecker4/optimization/parameter/Move.scala index 87b6555..0589498 100644 --- a/scala-source/com/barrybecker4/optimization/parameter/Move.scala +++ b/scala-source/com/barrybecker4/optimization/parameter/Move.scala @@ -17,3 +17,12 @@ final case class Swap(first: Int, second: Int) extends Move /** Several swaps, applied one after the other (a later swap sees the result of the earlier ones). */ final case class SwapSequence(swaps: IndexedSeq[Swap]) extends Move + +/** Set the parameter at `index` to `value`, for an array of integers (or booleans, which are 0 and 1). */ +final case class SetValue(index: Int, value: Double) extends Move + +/** + * A value at a position. It is what a move creates or destroys, as far as tabu search is concerned: after a swap, + * two values are in new positions, and putting either back would undo it. See [[ParameterArray.createdAttributes]]. + */ +final case class Placement(position: Int, value: Double) diff --git a/scala-source/com/barrybecker4/optimization/parameter/NumericParameterArray.scala b/scala-source/com/barrybecker4/optimization/parameter/NumericParameterArray.scala index 9457bfb..2c91269 100644 --- a/scala-source/com/barrybecker4/optimization/parameter/NumericParameterArray.scala +++ b/scala-source/com/barrybecker4/optimization/parameter/NumericParameterArray.scala @@ -13,6 +13,9 @@ object NumericParameterArray { /** default number of steps to go from the min to the max */ private val DEFAULT_NUM_STEPS = 10 + /** The most values that [[NumericParameterArray.movesAt]] offers for one position; a bigger range is sampled. */ + val MAX_VALUES_PER_POSITION = 64 + def createParams(vals: IndexedSeq[Double], minVals: IndexedSeq[Double], maxVals: IndexedSeq[Double], names: IndexedSeq[String]): IndexedSeq[Parameter] = { val len = vals.length @@ -120,6 +123,44 @@ case class NumericParameterArray(params: IndexedSeq[Parameter], override def getRandomNeighbor(radius: Double): NumericParameterArray = NumericParameterArray(this.params.map(p => p.tweakValue(radius, rnd)), numSteps, rnd) + /** Local moves set one integer parameter to another value. Arrays with real numbers do not have them. */ + override def supportsLocalMoves: Boolean = params.forall(_.isIntegerOnly) + + /** Setting the parameter to each other value in its range, or to a sample of them when the range is large. */ + override def movesAt(position: Int): Iterator[Move] = + otherValues(position, rnd).iterator.map(SetValue(position, _)) + + /** Set one random parameter to a different value in its range, if it has one. */ + override def proposeLocalMove(random: Random): Move = { + val position = random.nextInt(size) + val values = otherValues(position, random) + SetValue(position, if (values.isEmpty) get(position).getValue else values(random.nextInt(values.size))) + } + + override def applyMove(move: Move): NumericParameterArray = move match { + case SetValue(index, value) => NumericParameterArray(params.updated(index, params(index).setValue(value)), numSteps, rnd) + case other => throw new UnsupportedOperationException("A numeric array does not understand the move " + other) + } + + override def createdAttributes(move: Move): Seq[Any] = move match { + case SetValue(index, value) => Seq(Placement(index, value)) + case other => super.createdAttributes(other) + } + + override def destroyedAttributes(move: Move): Seq[Any] = move match { + case SetValue(index, _) => Seq(Placement(index, get(index).getValue)) + case other => super.destroyedAttributes(other) + } + + /** The integer values in the range of the parameter other than its own, or at most `MAX_VALUES_PER_POSITION` of them. */ + private def otherValues(position: Int, random: Random): IndexedSeq[Double] = { + val parameter = get(position) + val (low, high, current) = (parameter.minValue.round.toInt, parameter.maxValue.round.toInt, parameter.getValue.round.toInt) + val all = (low to high).filter(_ != current) + val sampled = if (all.size <= NumericParameterArray.MAX_VALUES_PER_POSITION) all else random.shuffle(all).take(NumericParameterArray.MAX_VALUES_PER_POSITION) + sampled.map(_.toDouble) + } + /** @return get a completely random solution in the parameter space.*/ override def getRandomSample: NumericParameterArray = { val newParams = diff --git a/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala b/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala index 1f3cded..efdbc61 100644 --- a/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala +++ b/scala-source/com/barrybecker4/optimization/parameter/ParameterArray.scala @@ -78,6 +78,41 @@ trait ParameterArray { def applyMove(move: Move): ParameterArray = throw new UnsupportedOperationException(getClass.getName + " does not support moves") + /** + * Whether this array can describe moves that each change one position, so that [[movesAt]], [[proposeLocalMove]] + * and [[applyMove]] work for them. Strategies that look at many such moves (tabu search, min-conflicts) need this. + * False by default. + * + * It is separate from [[supportsMoves]], which promises that a move is the neighbor that [[getRandomNeighbor]] would + * have made. For an array of integers the random neighbor changes every position, and a local move only one. + */ + def supportsLocalMoves: Boolean = false + + /** + * Every move that changes the given position: a swap with each other position for a permutation, or setting the + * position to each other value for integers. When there are too many values to list, a sample of them. + * @param position the index of the parameter to change + */ + def movesAt(position: Int): Iterator[Move] = + throw new UnsupportedOperationException(getClass.getName + " does not support local moves") + + /** + * A random move that changes one position, which the move that [[proposeMove]] gives does not necessarily do. + * @param rnd the source of randomness, so that a seeded search repeats + */ + def proposeLocalMove(rnd: Random): Move = + throw new UnsupportedOperationException(getClass.getName + " does not support local moves") + + /** + * What taking the move creates, for the tabu list: the values it puts at positions, as [[Placement]]s. A move + * that creates something that was recently destroyed is tabu. The default is the move itself, which makes only + * repeating the same move tabu. + */ + def createdAttributes(move: Move): Seq[Any] = Seq(move) + + /** What taking the move destroys, which is what the tabu list holds once the move is made. See [[createdAttributes]]. */ + def destroyedAttributes(move: Move): Seq[Any] = Seq(move) + /** * What makes two arrays of the same class the same candidate solution. Strategies that avoid evaluating or keeping * duplicates (such as genetic search) compare this, so equal identities must mean equal candidates. diff --git a/scala-source/com/barrybecker4/optimization/parameter/PermutedParameterArray.scala b/scala-source/com/barrybecker4/optimization/parameter/PermutedParameterArray.scala index 6755e69..0488c8f 100644 --- a/scala-source/com/barrybecker4/optimization/parameter/PermutedParameterArray.scala +++ b/scala-source/com/barrybecker4/optimization/parameter/PermutedParameterArray.scala @@ -110,6 +110,41 @@ case class PermutedParameterArray(params: IndexedSeq[Parameter], rnd: Random) case other => throw new UnsupportedOperationException("A permutation does not understand the move " + other) } + /** Local moves are swaps, for the same arrays that have moves: a subclass that builds its own neighbors has not opted in. */ + override def supportsLocalMoves: Boolean = supportsMoves + + override def movesAt(position: Int): Iterator[Move] = { + requireLocalMoves() + (0 until size).iterator.filter(_ != position).map(other => Swap(position, other)) + } + + private def requireLocalMoves(): Unit = + if (!supportsLocalMoves) throw new UnsupportedOperationException("This array makes its own neighbors, so it has no local moves") + + /** A swap of two different positions (of the same one, if there is only one position). */ + override def proposeLocalMove(rnd: Random): Move = { + requireLocalMoves() + if (size < 2) Swap(0, 0) + else { + val first = rnd.nextInt(size) + Swap(first, (first + 1 + rnd.nextInt(size - 1)) % size) + } + } + + /** A swap puts each value where the other was. */ + override def createdAttributes(move: Move): Seq[Any] = move match { + case Swap(first, second) => Seq(Placement(first, valueAt(second)), Placement(second, valueAt(first))) + case other => super.createdAttributes(other) + } + + /** A swap takes each value from where it was. */ + override def destroyedAttributes(move: Move): Seq[Any] = move match { + case Swap(first, second) => Seq(Placement(first, valueAt(first)), Placement(second, valueAt(second))) + case other => super.destroyedAttributes(other) + } + + private def valueAt(position: Int): Double = get(position).getValue + /** Replace only the parameters that change when a few positions are swapped, or work on the values if there are many. */ private def swapPositions(swaps: IndexedSeq[Swap]): PermutedParameterArray = if (swaps.size * PermutedParameterArray.FEW_SWAPS_DIVISOR <= size) { diff --git a/scala-test/com/barrybecker4/optimization/benchmark/AllIntervalProblem.scala b/scala-test/com/barrybecker4/optimization/benchmark/AllIntervalProblem.scala index be3385e..dd45602 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/AllIntervalProblem.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/AllIntervalProblem.scala @@ -28,10 +28,19 @@ class AllIntervalProblem(size: Int, seed: Long) extends OptimizeeProblem { /** The number of differences from 1 to n - 1 that are missing. */ override def evaluateFitness(series: ParameterArray): Double = { val present = new Array[Boolean](size) - (1 until size).foreach(i => present(Math.abs(numberAt(series, i) - numberAt(series, i - 1))) = true) + (1 until size).foreach(i => present(difference(series, i)) = true) (1 until size).count(!present(_)).toDouble } + /** The neighbors whose difference is also the difference of another pair of neighbors, both of them. */ + override def conflictedPositions(series: ParameterArray): Seq[Int] = { + val timesSeen = new Array[Int](size) + (1 until size).foreach(i => timesSeen(difference(series, i)) += 1) + (1 until size).filter(i => timesSeen(difference(series, i)) > 1).flatMap(i => Seq(i - 1, i)).distinct + } + + private def difference(series: ParameterArray, position: Int): Int = Math.abs(numberAt(series, position) - numberAt(series, position - 1)) + private def numberAt(series: ParameterArray, position: Int): Int = series.get(position).getValue.toInt override def getInitialGuess: ParameterArray = seriesOf(new Random(seed + 1).shuffle((0 until size).toList), seed + 2) diff --git a/scala-test/com/barrybecker4/optimization/benchmark/ConstraintHooksSuite.scala b/scala-test/com/barrybecker4/optimization/benchmark/ConstraintHooksSuite.scala new file mode 100644 index 0000000..4b4dae2 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/benchmark/ConstraintHooksSuite.scala @@ -0,0 +1,74 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.benchmark + +import com.barrybecker4.optimization.optimizee.DeltaCheckingOptimizee +import com.barrybecker4.optimization.parameter.{ParameterArray, SetValue} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** The constraint problems tell which positions are in conflict, and score a change of one position by its delta. */ +class ConstraintHooksSuite extends AnyFunSuite { + + private def valuesOf(pa: ParameterArray): Seq[Int] = (0 until pa.size).map(i => pa.get(i).getValue.toInt) + + test("the conflicted queens are exactly the ones that another queen attacks") { + val problem = new NQueensProblem(12, 3) + val board = problem.getInitialGuess + val columns = valuesOf(board) + val attacked = (0 until 12).filter(r => (0 until 12).exists(o => o != r && Math.abs(o - r) == Math.abs(columns(o) - columns(r)))) + assertResult(attacked)(problem.conflictedPositions(board)) + assert(attacked.nonEmpty) + } + + test("a solved board has no conflicted queens") { + assertResult(Seq.empty)(new NQueensProblem(10, 1).conflictedPositions(new NQueensProblem(10, 1).getExactSolution.pa)) + } + + test("the conflicted nodes are the ones with a neighbor of their color, and there are none in the solution") { + val problem = new GraphColoringProblem(40, 3, 5.0, 1) + val guess = problem.getInitialGuess + val conflicted = problem.conflictedPositions(guess) + assert(conflicted.nonEmpty && conflicted.size < 40) + assertResult(Seq.empty)(problem.conflictedPositions(problem.getExactSolution.pa)) + assertResult(0.0)(problem.evaluateFitness(problem.getExactSolution.pa)) + } + + test("a node with an edge that clashes is reported, and recoloring all the reported nodes can fix the clash count to zero") { + val problem = new GraphColoringProblem(30, 3, 4.0, 2) + val guess = problem.getInitialGuess + assert(problem.evaluateFitness(guess) > 0 && problem.conflictedPositions(guess).nonEmpty) + } + + test("the clashes after recoloring a node from evaluateMove are those of the recolored graph, for every node and color") { + val problem = new GraphColoringProblem(30, 4, 5.0, 3) + val coloring = problem.getInitialGuess + val clashes = problem.evaluateFitness(coloring) + for (node <- 0 until 30; color <- 0 until 4) { + val move = SetValue(node, color) + assertResult(problem.evaluateFitness(coloring.applyMove(move)), s"node $node color $color")(problem.evaluateMove(coloring, clashes, move)) + } + } + + test("a delta checking wrapper accepts every recoloring that coloring scores by delta") { + val problem = new DeltaCheckingOptimizee(new GraphColoringProblem(30, 4, 5.0, 4)) + val coloring = new GraphColoringProblem(30, 4, 5.0, 4).getInitialGuess + val start = problem.evaluateFitness(coloring) + val random = new Random(1) + (1 to 100).foreach(_ => problem.evaluateMove(coloring, start, coloring.proposeLocalMove(random))) + } + + test("the conflicted positions of the all-interval series are the ends of repeated differences") { + val problem = new AllIntervalProblem(8, 1) + val series = problem.getInitialGuess + val values = valuesOf(series) + val differences = (1 until 8).map(i => Math.abs(values(i) - values(i - 1))) + val expected = (1 until 8).filter(i => differences.count(_ == differences(i - 1)) > 1).flatMap(i => Seq(i - 1, i)).distinct + assertResult(expected)(problem.conflictedPositions(series)) + } + + test("a solved series has no conflicted positions") { + val problem = new AllIntervalProblem(14, 1) + assertResult(Seq.empty)(problem.conflictedPositions(problem.getExactSolution.pa)) + } +} diff --git a/scala-test/com/barrybecker4/optimization/benchmark/GraphColoringProblem.scala b/scala-test/com/barrybecker4/optimization/benchmark/GraphColoringProblem.scala index 24938ac..8c7716e 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/GraphColoringProblem.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/GraphColoringProblem.scala @@ -3,7 +3,7 @@ package com.barrybecker4.optimization.benchmark import com.barrybecker4.optimization.optimizee.optimizees.OptimizeeProblem import com.barrybecker4.optimization.parameter.types.{IntegerParameter, Parameter} -import com.barrybecker4.optimization.parameter.{NumericParameterArray, ParameterArray, ParameterArrayWithFitness} +import com.barrybecker4.optimization.parameter.{Move, NumericParameterArray, ParameterArray, ParameterArrayWithFitness, SetValue} import scala.util.Random @@ -28,6 +28,7 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double, private val random = new Random(seed) private val hiddenColoring: IndexedSeq[Int] = IndexedSeq.fill(numNodes)(random.nextInt(numColors)) private val edges: IndexedSeq[(Int, Int)] = createEdges() + private val neighbors: IndexedSeq[Array[Int]] = neighborsOfEachNode() override def getName: String = s"Coloring $numNodes nodes with $numColors colors" @@ -37,6 +38,17 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double, override def evaluateFitness(coloring: ParameterArray): Double = edges.count { case (a, b) => colorOf(coloring, a) == colorOf(coloring, b) }.toDouble + /** The nodes that share a color with a node they are joined to. */ + override def conflictedPositions(coloring: ParameterArray): Seq[Int] = + (0 until numNodes).filter(node => neighbors(node).exists(colorOf(coloring, _) == colorOf(coloring, node))) + + /** Giving a node another color only changes its own edges: the ones that now clash less the ones that clashed. */ + override def evaluateMove(coloring: ParameterArray, clashes: Double, move: Move): Double = move match { + case SetValue(node, color) => + clashes + neighbors(node).count(colorOf(coloring, _) == color.toInt) - neighbors(node).count(colorOf(coloring, _) == colorOf(coloring, node)) + case _ => super.evaluateMove(coloring, clashes, move) + } + private def colorOf(coloring: ParameterArray, node: Int): Int = coloring.get(node).getValue.toInt override def getInitialGuess: ParameterArray = { @@ -59,6 +71,11 @@ class GraphColoringProblem(numNodes: Int, numColors: Int, averageDegree: Double, NumericParameterArray(params, rnd = new Random(randomSeed)) } + private def neighborsOfEachNode(): IndexedSeq[Array[Int]] = + (0 until numNodes).map { node => + edges.collect { case (a, b) if a == node => b; case (a, b) if b == node => a }.toArray + } + /** Random pairs of nodes with different hidden colors, until there are enough edges; the same edge is added once. */ private def createEdges(): IndexedSeq[(Int, Int)] = { val wanted = (numNodes * averageDegree / 2).toInt diff --git a/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala b/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala index 86c5e4a..f47ad28 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/LimitedOptimizee.scala @@ -46,6 +46,7 @@ class LimitedOptimizee(delegate: Optimizee, maxEvaluations: Long, deadlineNanos: override def repair(candidate: ParameterArray): ParameterArray = delegate.repair(candidate) override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = delegate.proposeGuidedMove(current, radius, rnd) + override def conflictedPositions(current: ParameterArray): Seq[Int] = delegate.conflictedPositions(current) override def evaluateFitness(params: ParameterArray): Double = { checkLimits() diff --git a/scala-test/com/barrybecker4/optimization/benchmark/NQueensProblem.scala b/scala-test/com/barrybecker4/optimization/benchmark/NQueensProblem.scala index 49c5b99..2935045 100644 --- a/scala-test/com/barrybecker4/optimization/benchmark/NQueensProblem.scala +++ b/scala-test/com/barrybecker4/optimization/benchmark/NQueensProblem.scala @@ -43,6 +43,17 @@ class NQueensProblem(size: Int, seed: Long) extends OptimizeeProblem { private def columnOf(board: ParameterArray, row: Int): Int = board.get(row).getValue.toInt + /** The rows of the queens that another queen attacks. */ + override def conflictedPositions(board: ParameterArray): Seq[Int] = { + val rising = new Array[Int](2 * size) + val falling = new Array[Int](2 * size) + (0 until size).foreach { row => + rising(row + columnOf(board, row)) += 1 + falling(row - columnOf(board, row) + size) += 1 + } + (0 until size).filter(row => rising(row + columnOf(board, row)) > 1 || falling(row - columnOf(board, row) + size) > 1) + } + /** Only the two swapped queens change which diagonals they are on, so only their conflicts are worked out again. */ override def evaluateMove(board: ParameterArray, conflicts: Double, move: Move): Double = move match { case Swap(first, second) if first != second => conflicts + conflictChangeOfSwap(board, first, second) diff --git a/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala b/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala index 9623ff9..e8ac57e 100644 --- a/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala +++ b/scala-test/com/barrybecker4/optimization/optimizee/OptimizeeHooksSuite.scala @@ -15,6 +15,8 @@ class OptimizeeHooksSuite extends AnyFunSuite { private class Opinionated extends RandomTourProblem(8, 1) { var repairs = 0 var guided = 0 + var conflicts = 0 + override def conflictedPositions(current: ParameterArray): Seq[Int] = { conflicts += 1; Seq(3, 4) } override def repair(candidate: ParameterArray): ParameterArray = { repairs += 1; candidate } override def proposeGuidedMove(current: ParameterArray, radius: Double, rnd: Random): Option[Move] = { guided += 1 @@ -61,4 +63,16 @@ class OptimizeeHooksSuite extends AnyFunSuite { checked.proposeGuidedMove(tour, 0.5, new Random(1)) assertResult((1, 1))((inner.repairs, inner.guided)) } + + test("by default no position is reported as conflicted, which means the problem does not know") { + assertResult(Seq.empty)(plain.conflictedPositions(plainTour)) + } + + test("the wrappers pass the conflicted positions on, without counting an evaluation") { + val budgeted = new BudgetedOptimizee(new Opinionated(), maxEvaluations = 1) + assertResult(Seq(3, 4))(budgeted.conflictedPositions(tour)) + assertResult(0)(budgeted.evaluationsUsed) + assertResult(Seq(3, 4))(new LimitedOptimizee(new Opinionated(), 1, Long.MaxValue).conflictedPositions(tour)) + assertResult(Seq(3, 4))(new DeltaCheckingOptimizee(new Opinionated()).conflictedPositions(tour)) + } } diff --git a/scala-test/com/barrybecker4/optimization/parameter/LocalMovesSuite.scala b/scala-test/com/barrybecker4/optimization/parameter/LocalMovesSuite.scala new file mode 100644 index 0000000..e15eec1 --- /dev/null +++ b/scala-test/com/barrybecker4/optimization/parameter/LocalMovesSuite.scala @@ -0,0 +1,124 @@ +// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT +package com.barrybecker4.optimization.parameter + +import com.barrybecker4.optimization.parameter.types.{BooleanParameter, DoubleParameter, IntegerParameter, Parameter} +import org.scalatest.funsuite.AnyFunSuite + +import scala.util.Random + +/** Moves that change one position: swaps for permutations, a new value for integers and booleans. */ +class LocalMovesSuite extends AnyFunSuite { + + private def permutation(size: Int, seed: Long): PermutedParameterArray = { + val order = new Random(seed).shuffle((0 until size).toList) + PermutedParameterArray(order.indices.map(i => IntegerParameter(order(i), 0, size - 1, "p" + i): Parameter), new Random(seed)) + } + + private def integers(values: Seq[Int], low: Int, high: Int): NumericParameterArray = + NumericParameterArray(values.indices.map(i => IntegerParameter(values(i), low, high, "v" + i): Parameter), rnd = new Random(1)) + + private def valuesOf(pa: ParameterArray): Seq[Int] = (0 until pa.size).map(i => pa.get(i).getValue.toInt) + + test("a permutation has local moves, but a subclass that makes its own neighbors does not") { + assert(permutation(6, 1).supportsLocalMoves) + assert(!new PuzzleLikePath(permutation(6, 1).params, "path").supportsLocalMoves) + } + + test("the moves at a position are a swap with each other position") { + val moves = permutation(6, 1).movesAt(2).toList + assertResult(List(Swap(2, 0), Swap(2, 1), Swap(2, 3), Swap(2, 4), Swap(2, 5)))(moves) + } + + test("a random local move of a permutation swaps two different positions, and repeats for the same seed") { + val array = permutation(8, 1) + val random = new Random(7) + val moves = (1 to 200).map(_ => array.proposeLocalMove(random)) + moves.foreach { case Swap(a, b) => assert(a != b && a >= 0 && b >= 0 && a < 8 && b < 8) } + assertResult(array.proposeLocalMove(new Random(5)))(array.proposeLocalMove(new Random(5))) + assert(moves.map { case Swap(a, _) => a }.toSet.size == 8, "every position gets chosen") + } + + test("a swap creates the values in their new places and destroys them in their old ones") { + val array = permutation(5, 2) + val (a, b) = (array.get(1).getValue, array.get(3).getValue) + assertResult(Seq(Placement(1, b), Placement(3, a)))(array.createdAttributes(Swap(1, 3))) + assertResult(Seq(Placement(1, a), Placement(3, b)))(array.destroyedAttributes(Swap(1, 3))) + } + + test("what a swap destroys is what the swap back would create, which is how a tabu list forbids undoing it") { + val array = permutation(7, 3) + val after = array.applyMove(Swap(2, 5)) + assertResult(array.destroyedAttributes(Swap(2, 5)))(after.createdAttributes(Swap(2, 5))) + } + + test("a move that the array does not know is its own attribute") { + val move = new Move {} + assertResult(Seq(move))(permutation(4, 1).createdAttributes(move)) + assertResult(Seq(move))(integers(Seq(1, 2), 0, 3).destroyedAttributes(move)) + } + + test("an array of integers has local moves, and one with a real number does not") { + assert(integers(Seq(0, 1, 2), 0, 3).supportsLocalMoves) + val mixed = NumericParameterArray(IndexedSeq[Parameter](IntegerParameter(1, 0, 3, "a"), DoubleParameter(1.0, 0.0, 2.0, "b"))) + assert(!mixed.supportsLocalMoves) + assert(!mixed.supportsMoves, "the neighbors of numeric arrays are still built the old way") + intercept[UnsupportedOperationException](mixed.proposeMove(1.0)) + } + + test("an array of booleans has local moves") { + val array = NumericParameterArray(IndexedSeq[Parameter](BooleanParameter(true, "a"), BooleanParameter(false, "b"))) + assert(array.supportsLocalMoves) + assertResult(List(SetValue(0, 0.0)))(array.movesAt(0).toList) + assertResult(List(SetValue(1, 1.0)))(array.movesAt(1).toList) + } + + test("the moves at a position set it to each other value in its range") { + val array = integers(Seq(2, 0, 1), 0, 3) + assertResult(List(SetValue(0, 0.0), SetValue(0, 1.0), SetValue(0, 3.0)))(array.movesAt(0).toList) + } + + test("a range that is large is sampled, so the number of moves is limited") { + val array = integers(Seq(5), 0, 100_000) + val moves = array.movesAt(0).toList + assertResult(NumericParameterArray.MAX_VALUES_PER_POSITION)(moves.size) + assert(moves.map { case SetValue(_, v) => v }.forall(v => v != 5.0 && v >= 0 && v <= 100_000)) + } + + test("a random local move of integers changes one position to a different value in range") { + val array = integers(Seq(1, 2, 3, 0, 1), 0, 3) + (1 to 200).foreach { seed => + val SetValue(index, value) = array.proposeLocalMove(new Random(seed)): @unchecked + assert(index >= 0 && index < 5 && value >= 0 && value <= 3 && value != valuesOf(array)(index)) + } + } + + test("a parameter with only one possible value is set to itself") { + val SetValue(0, value) = integers(Seq(2), 2, 2).proposeLocalMove(new Random(1)): @unchecked + assertResult(2.0)(value) + } + + test("applying a move sets just that position, and leaves the array it was applied to as it was") { + val array = integers(Seq(1, 2, 3), 0, 3) + val after = array.applyMove(SetValue(1, 0.0)) + assertResult(Seq(1, 0, 3))(valuesOf(after)) + assertResult(Seq(1, 2, 3))(valuesOf(array)) + } + + test("a move that is not a value being set is refused") { + intercept[UnsupportedOperationException](integers(Seq(1), 0, 3).applyMove(Swap(0, 0))) + } + + test("setting a value creates it at the position and destroys the old one, so setting it back is tabu") { + val array = integers(Seq(1, 2, 3), 0, 3) + assertResult(Seq(Placement(1, 0.0)))(array.createdAttributes(SetValue(1, 0.0))) + assertResult(Seq(Placement(1, 2.0)))(array.destroyedAttributes(SetValue(1, 0.0))) + val after = array.applyMove(SetValue(1, 0.0)) + assertResult(array.destroyedAttributes(SetValue(1, 0.0)))(after.createdAttributes(SetValue(1, 2.0))) + } + + test("an array that has no local moves refuses to give them") { + val array = new PuzzleLikePath(permutation(4, 1).params, "path") + intercept[UnsupportedOperationException](array.movesAt(0)) + intercept[UnsupportedOperationException](array.proposeLocalMove(new Random(1))) + } +}