diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java index 2e18cf64367..74a71e27f4c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java @@ -17,6 +17,7 @@ import ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance.LateAcceptanceAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.simulatedannealing.SimulatedAnnealingAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.stepcountinghillclimbing.StepCountingHillClimbingAcceptor; +import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.AbstractTabuAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.EntityTabuAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.MoveTabuAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.ValueTabuAcceptor; @@ -54,15 +55,14 @@ public Acceptor buildAcceptor(HeuristicConfigPolicy config .collect(Collectors.toList()); if (acceptorList.size() == 1) { - return acceptorList.get(0); + return acceptorList.getFirst(); } else if (acceptorList.size() > 1) { return new CompositeAcceptor<>(acceptorList); } else { - throw new IllegalArgumentException( - "The acceptor does not specify any acceptorType (" + acceptorConfig.getAcceptorTypeList() - + ") or other acceptor property.\n" - + "For a good starting values," - + " see the docs section \"Which optimization algorithms should I use?\"."); + throw new IllegalArgumentException(""" + The acceptor does not specify any acceptorType (%s) or other acceptor property. + For good starting values, see the docs section "Which optimization algorithms should I use?".""" + .formatted(acceptorConfig.getAcceptorTypeList())); } } @@ -94,34 +94,35 @@ private Optional> buildStepCountingH } private Optional> buildEntityTabuAcceptor(HeuristicConfigPolicy configPolicy) { + var entityTabuSize = acceptorConfig.getEntityTabuSize(); + var entityTabuRatio = acceptorConfig.getEntityTabuRatio(); + var fadingEntityTabuSize = acceptorConfig.getFadingEntityTabuSize(); + var fadingEntityTabuRatio = acceptorConfig.getFadingEntityTabuRatio(); if (acceptorTypeListsContainsAcceptorType(AcceptorType.ENTITY_TABU) - || acceptorConfig.getEntityTabuSize() != null || acceptorConfig.getEntityTabuRatio() != null - || acceptorConfig.getFadingEntityTabuSize() != null || acceptorConfig.getFadingEntityTabuRatio() != null) { + || entityTabuSize != null || entityTabuRatio != null + || fadingEntityTabuSize != null || fadingEntityTabuRatio != null) { var acceptor = new EntityTabuAcceptor(configPolicy.getLogIndentation()); - if (acceptorConfig.getEntityTabuSize() != null) { - if (acceptorConfig.getEntityTabuRatio() != null) { - throw new IllegalArgumentException("The acceptor cannot have both acceptorConfig.getEntityTabuSize() (" - + acceptorConfig.getEntityTabuSize() + ") and acceptorConfig.getEntityTabuRatio() (" - + acceptorConfig.getEntityTabuRatio() + ")."); + if (entityTabuSize != null) { + if (entityTabuRatio != null) { + throw new IllegalArgumentException( + "The acceptor cannot have both entityTabuSize (%d) and entityTabuRatio (%f)." + .formatted(entityTabuSize, entityTabuRatio)); } - acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getEntityTabuSize())); - } else if (acceptorConfig.getEntityTabuRatio() != null) { - acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(acceptorConfig.getEntityTabuRatio())); - } else if (acceptorConfig.getFadingEntityTabuSize() == null && acceptorConfig.getFadingEntityTabuRatio() == null) { + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(entityTabuSize)); + } else if (entityTabuRatio != null) { + acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(entityTabuRatio)); + } else if (fadingEntityTabuSize == null && fadingEntityTabuRatio == null) { acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(0.1)); } - if (acceptorConfig.getFadingEntityTabuSize() != null) { - if (acceptorConfig.getFadingEntityTabuRatio() != null) { + if (fadingEntityTabuSize != null) { + if (fadingEntityTabuRatio != null) { throw new IllegalArgumentException( - "The acceptor cannot have both acceptorConfig.getFadingEntityTabuSize() (" - + acceptorConfig.getFadingEntityTabuSize() - + ") and acceptorConfig.getFadingEntityTabuRatio() (" - + acceptorConfig.getFadingEntityTabuRatio() + ")."); + "The acceptor cannot have both fadingEntityTabuSize (%d) and fadingEntityTabuRatio (%f)." + .formatted(fadingEntityTabuSize, fadingEntityTabuRatio)); } - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingEntityTabuSize())); - } else if (acceptorConfig.getFadingEntityTabuRatio() != null) { - acceptor.setFadingTabuSizeStrategy( - new EntityRatioTabuSizeStrategy<>(acceptorConfig.getFadingEntityTabuRatio())); + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingEntityTabuSize)); + } else if (fadingEntityTabuRatio != null) { + acceptor.setFadingTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(fadingEntityTabuRatio)); } if (configPolicy.getEnvironmentMode().isFullyAsserted()) { acceptor.setAssertTabuHashCodeCorrectness(true); @@ -132,43 +133,47 @@ private Optional> buildEntityTabuAcceptor(Heuristi } private Optional> buildValueTabuAcceptor(HeuristicConfigPolicy configPolicy) { + var valueTabuSize = acceptorConfig.getValueTabuSize(); + var fadingValueTabuSize = acceptorConfig.getFadingValueTabuSize(); if (acceptorTypeListsContainsAcceptorType(AcceptorType.VALUE_TABU) - || acceptorConfig.getValueTabuSize() != null || acceptorConfig.getFadingValueTabuSize() != null) { - var acceptor = new ValueTabuAcceptor(configPolicy.getLogIndentation()); - if (acceptorConfig.getValueTabuSize() != null) { - acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getValueTabuSize())); - } - if (acceptorConfig.getFadingValueTabuSize() != null) { - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingValueTabuSize())); - } - - if (acceptorConfig.getValueTabuSize() != null) { - acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getValueTabuSize())); - } - if (acceptorConfig.getFadingValueTabuSize() != null) { - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingValueTabuSize())); - } - if (configPolicy.getEnvironmentMode().isFullyAsserted()) { - acceptor.setAssertTabuHashCodeCorrectness(true); + || valueTabuSize != null || fadingValueTabuSize != null) { + if (valueTabuSize == null && fadingValueTabuSize == null) { + throw new IllegalArgumentException( + "The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured." + .formatted(AcceptorType.VALUE_TABU)); } + var acceptor = new ValueTabuAcceptor(configPolicy.getLogIndentation()); + configureFixedSizeTabuAcceptor(acceptor, configPolicy, valueTabuSize, fadingValueTabuSize); return Optional.of(acceptor); } return Optional.empty(); } + private static void configureFixedSizeTabuAcceptor(AbstractTabuAcceptor acceptor, + HeuristicConfigPolicy configPolicy, Integer tabuSize, Integer fadingTabuSize) { + if (tabuSize != null) { + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(tabuSize)); + } + if (fadingTabuSize != null) { + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingTabuSize)); + } + if (configPolicy.getEnvironmentMode().isFullyAsserted()) { + acceptor.setAssertTabuHashCodeCorrectness(true); + } + } + private Optional> buildMoveTabuAcceptor(HeuristicConfigPolicy configPolicy) { + var moveTabuSize = acceptorConfig.getMoveTabuSize(); + var fadingMoveTabuSize = acceptorConfig.getFadingMoveTabuSize(); if (acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU) - || acceptorConfig.getMoveTabuSize() != null || acceptorConfig.getFadingMoveTabuSize() != null) { - var acceptor = new MoveTabuAcceptor(configPolicy.getLogIndentation()); - if (acceptorConfig.getMoveTabuSize() != null) { - acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getMoveTabuSize())); - } - if (acceptorConfig.getFadingMoveTabuSize() != null) { - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingMoveTabuSize())); - } - if (configPolicy.getEnvironmentMode().isFullyAsserted()) { - acceptor.setAssertTabuHashCodeCorrectness(true); + || moveTabuSize != null || fadingMoveTabuSize != null) { + if (moveTabuSize == null && fadingMoveTabuSize == null) { + throw new IllegalArgumentException( + "The acceptorType (%s) requires either moveTabuSize or fadingMoveTabuSize to be configured." + .formatted(AcceptorType.MOVE_TABU)); } + var acceptor = new MoveTabuAcceptor(configPolicy.getLogIndentation()); + configureFixedSizeTabuAcceptor(acceptor, configPolicy, moveTabuSize, fadingMoveTabuSize); return Optional.of(acceptor); } return Optional.empty(); @@ -181,9 +186,9 @@ private Optional> buildMoveTabuAcceptor(HeuristicCon var acceptor = new SimulatedAnnealingAcceptor(); if (acceptorConfig.getSimulatedAnnealingStartingTemperature() == null) { // TODO Support SA without a parameter - throw new IllegalArgumentException("The acceptorType (" + AcceptorType.SIMULATED_ANNEALING - + ") currently requires a acceptorConfig.getSimulatedAnnealingStartingTemperature() (" - + acceptorConfig.getSimulatedAnnealingStartingTemperature() + ")."); + throw new IllegalArgumentException( + "The acceptorType (%s) requires non-null acceptorConfig.getSimulatedAnnealingStartingTemperature()." + .formatted(AcceptorType.SIMULATED_ANNEALING)); } acceptor.setStartingTemperature( configPolicy.getScoreDefinition().parseScore(acceptorConfig.getSimulatedAnnealingStartingTemperature())); @@ -221,19 +226,20 @@ private Optional> buildGreatDelugeAcceptor(Heuris var acceptor = new GreatDelugeAcceptor(); if (acceptorConfig.getGreatDelugeWaterLevelIncrementScore() != null) { if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { - throw new IllegalArgumentException("The acceptor cannot have both a " - + "acceptorConfig.getGreatDelugeWaterLevelIncrementScore() (" - + acceptorConfig.getGreatDelugeWaterLevelIncrementScore() - + ") and a acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (" - + acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() + ")."); + throw new IllegalArgumentException(""" + The acceptor cannot have both acceptorConfig.getGreatDelugeWaterLevelIncrementScore() (%s) \ + and acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s).""" + .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementScore(), + acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); } acceptor.setWaterLevelIncrementScore( configPolicy.getScoreDefinition().parseScore(acceptorConfig.getGreatDelugeWaterLevelIncrementScore())); } else if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() <= 0.0) { - throw new IllegalArgumentException("The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (" - + acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() - + ") must be positive because the water level should increase."); + throw new IllegalArgumentException(""" + The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s) must be positive \ + because the water level should increase.""" + .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); } acceptor.setWaterLevelIncrementRatio(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()); } else { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java index 893ff43a3a4..a3d3f7f269b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java @@ -142,16 +142,14 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { logIndentation, moveScope.getMove()); return false; } - var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize); - var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance; + var decision = decideFadingTabuAcceptance(moveScope, tabuStepCount - workingTabuSize); + var accepted = decision > 0; if (accepted) { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is accepted.", - logIndentation, - moveScope.getMove(), acceptChance); + logIndentation, moveScope.getMove(), Math.abs(decision)); } else { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is not accepted.", - logIndentation, - moveScope.getMove(), acceptChance); + logIndentation, moveScope.getMove(), Math.abs(decision)); } return accepted; } @@ -185,12 +183,24 @@ private int locateMaximumTabuStepIndex(LocalSearchMoveScope moveScope /** * @param fadingTabuStepCount {@code 0 < fadingTabuStepCount <= fadingTabuSize} - * @return {@code 0.0 < acceptChance < 1.0} + * @return in absolute value, the accept chance; + * negative signum or 0 means not accepted, positive signum means accepted. + * This is hacky, but we can represent 2 things with one number + * and avoid allocation new types for this multiple return. */ - protected double calculateFadingTabuAcceptChance(int fadingTabuStepCount) { - // The + 1's are because acceptChance should not be 0.0 or 1.0 - // when (fadingTabuStepCount == 0) or (fadingTabuStepCount + 1 == workingFadingTabuSize) - return (workingFadingTabuSize - fadingTabuStepCount) / ((double) (workingFadingTabuSize + 1)); + private double decideFadingTabuAcceptance(LocalSearchMoveScope moveScope, int fadingTabuStepCount) { + // Invert the chance; the longer the element is in the tabu list, the higher the chance should be. + var numerator = workingFadingTabuSize - fadingTabuStepCount; + if (numerator <= 0) { // The inverted chance would be >= 1. + return 1.0d; + } + var denominator = workingFadingTabuSize + 1; + if (numerator >= denominator) { // The inverted chance would be <= 0. + return 0.0d; + } + var acceptChance = 1.0d - (numerator / (double) denominator); + var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance; + return accepted ? acceptChance : -acceptChance; } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategy.java index 6ed26eac2e6..219f4f442c2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategy.java @@ -8,9 +8,9 @@ public final class FixedTabuSizeStrategy extends AbstractTabuSizeStra public FixedTabuSizeStrategy(int tabuSize) { this.tabuSize = tabuSize; - if (tabuSize < 0) { - throw new IllegalArgumentException("The tabuSize (" + tabuSize - + ") cannot be negative."); + if (tabuSize < 1) { + throw new IllegalArgumentException("The tabuSize (%d) must be at least 1." + .formatted(tabuSize)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java index 581f559f980..c00a3978f73 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java @@ -110,4 +110,22 @@ void diversifiedLateAcceptanceAcceptor() { AcceptorFactory badAcceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); assertThatIllegalStateException().isThrownBy(() -> badAcceptorFactory.buildAcceptor(heuristicConfigPolicy)); } + + @Test + void valueTabuWithoutSizes_throwsException() { + var config = new LocalSearchAcceptorConfig() + .withAcceptorTypeList(List.of(AcceptorType.VALUE_TABU)); + var factory = AcceptorFactory.create(config); + assertThatIllegalArgumentException() + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); + } + + @Test + void moveTabuWithoutSizes_throwsException() { + var config = new LocalSearchAcceptorConfig() + .withAcceptorTypeList(List.of(AcceptorType.MOVE_TABU)); + var factory = AcceptorFactory.create(config); + assertThatIllegalArgumentException() + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/EntityTabuAcceptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/EntityTabuAcceptorTest.java index 2d975b3d6d2..02036e05e40 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/EntityTabuAcceptorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/EntityTabuAcceptorTest.java @@ -14,6 +14,7 @@ import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testutil.TestRandom; import org.junit.jupiter.api.Test; @@ -237,6 +238,82 @@ void aspiration() { acceptor.phaseEnded(phaseScope); } + @Test + void fadingTabuSize() { + var acceptor = new EntityTabuAcceptor<>(""); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2)); + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(4)); + + var e0 = new TestdataEntity("e0"); + var e1 = new TestdataEntity("e1"); + + var solverScope = new SolverScope<>(); + solverScope.setInitializedBestScore(SimpleScore.ZERO); + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + // Step 0: tabu e1 at stepIndex=0 + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + stepScope0.setStep(buildMoveScope(stepScope0, e1).getMove()); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + // Steps 1-2: hard tabu (tabuStepCount 1,2 ≤ 2) — no random consumed + var stepScope1 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, e1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, e0))).isTrue(); + stepScope1.setStep(buildMoveScope(stepScope1, e0).getMove()); + acceptor.stepEnded(stepScope1); + phaseScope.setLastCompletedStepScope(stepScope1); + + var stepScope2 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, e1))).isFalse(); + stepScope2.setStep(buildMoveScope(stepScope2, e0).getMove()); + acceptor.stepEnded(stepScope2); + phaseScope.setLastCompletedStepScope(stepScope2); + + // Step 3: fading zone, fadingCount=1, acceptChance=0.4; random=0.5 → rejected + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, e1))).isFalse(); + stepScope3.setStep(buildMoveScope(stepScope3, e0).getMove()); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + // Step 4: fadingCount=2, acceptChance=0.6; random=0.5 → accepted + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, e1))).isTrue(); + stepScope4.setStep(buildMoveScope(stepScope4, e0).getMove()); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + // Step 5: fadingCount=3, acceptChance=0.8; random=0.1 → accepted + solverScope.setWorkingRandom(new TestRandom(0.1)); + var stepScope5 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, e1))).isTrue(); + stepScope5.setStep(buildMoveScope(stepScope5, e0).getMove()); + acceptor.stepEnded(stepScope5); + phaseScope.setLastCompletedStepScope(stepScope5); + + // Step 6: fadingCount=4, acceptChance=1.0; random not consumed and accepted + // adjustTabuList removes e1 (tabuStepCount=6 ≥ totalTabuListSize=6) + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, e1))).isTrue(); + stepScope6.setStep(buildMoveScope(stepScope6, e0).getMove()); + acceptor.stepEnded(stepScope6); + phaseScope.setLastCompletedStepScope(stepScope6); + + // Step 7: e1 expired, no random consumed + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope7 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, e1))).isTrue(); + + acceptor.phaseEnded(phaseScope); + } + private static LocalSearchMoveScope buildMoveScope(LocalSearchStepScope stepScope, TestdataEntity... entities) { return buildMoveScope(stepScope, 0, entities); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/MoveTabuAcceptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/MoveTabuAcceptorTest.java new file mode 100644 index 00000000000..def246dab6f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/MoveTabuAcceptorTest.java @@ -0,0 +1,213 @@ +package ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.size.FixedTabuSizeStrategy; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchMoveScope; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.testutil.TestRandom; + +import org.junit.jupiter.api.Test; + +class MoveTabuAcceptorTest { + + @Test + void tabuSize() { + var acceptor = new MoveTabuAcceptor<>(""); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2)); + + var m0 = mock(Move.class); + var m1 = mock(Move.class); + var m2 = mock(Move.class); + var m3 = mock(Move.class); + var m4 = mock(Move.class); + + var solverScope = new SolverScope<>(); + solverScope.setInitializedBestScore(SimpleScore.ZERO); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + // Step 0: map is empty — all moves accepted + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope0, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope0, m1))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope0, m2))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope0, m3))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope0, m4))).isTrue(); + stepScope0.setStep(m1); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + // Step 1: map={m1:0}; m1 tabuStepCount=1 ≤ 2 → tabu + var stepScope1 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m2))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m3))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m4))).isTrue(); + stepScope1.setStep(m2); + acceptor.stepEnded(stepScope1); + phaseScope.setLastCompletedStepScope(stepScope1); + + // Step 2: map={m1:0,m2:1}; both tabu + var stepScope2 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m2))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m3))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m4))).isTrue(); + // adjustTabuList(2,[m3]): m1 tabuStepCount=2 ≥ 2 → removed; map={m2:1,m3:2} + stepScope2.setStep(m3); + acceptor.stepEnded(stepScope2); + phaseScope.setLastCompletedStepScope(stepScope2); + + // Step 3: map={m2:1,m3:2}; m1 expired + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m1))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m2))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m3))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m4))).isTrue(); + // adjustTabuList(3,[m4]): m2 tabuStepCount=2 ≥ 2 → removed; map={m3:2,m4:3} + stepScope3.setStep(m4); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + // Step 4: map={m3:2,m4:3}; m2 expired + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m1))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m2))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m3))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m4))).isFalse(); + stepScope4.setStep(m0); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + acceptor.phaseEnded(phaseScope); + } + + @Test + void aspiration() { + var acceptor = new MoveTabuAcceptor<>(""); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2)); + acceptor.setAspirationEnabled(true); + + var m0 = mock(Move.class); + var m1 = mock(Move.class); + + var solverScope = new SolverScope<>(); + solverScope.setInitializedBestScore(SimpleScore.of(-100)); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + stepScope0.setStep(m1); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + var stepScope1 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, -120, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, -20, m0))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, -120, m1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, -20, m1))).isTrue(); + + acceptor.phaseEnded(phaseScope); + } + + @Test + void fadingTabuSize() { + var acceptor = new MoveTabuAcceptor<>(""); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2)); + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(4)); + + var m0 = mock(Move.class); + var m1 = mock(Move.class); + + var solverScope = new SolverScope<>(); + solverScope.setInitializedBestScore(SimpleScore.ZERO); + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + // Step 0: tabu m1 at stepIndex=0 + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + stepScope0.setStep(m1); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + // Steps 1-2: hard tabu — no random consumed + var stepScope1 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, m0))).isTrue(); + stepScope1.setStep(m0); + acceptor.stepEnded(stepScope1); + phaseScope.setLastCompletedStepScope(stepScope1); + + var stepScope2 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, m1))).isFalse(); + stepScope2.setStep(m0); + acceptor.stepEnded(stepScope2); + phaseScope.setLastCompletedStepScope(stepScope2); + + // Step 3: fadingCount=1, acceptChance=0.4; random=0.3 → accepted + solverScope.setWorkingRandom(new TestRandom(0.3)); + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, m1))).isTrue(); + stepScope3.setStep(m0); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + // Step 4: fadingCount=2, acceptChance=0.6; random=0.5 → accepted + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m1))).isTrue(); + stepScope4.setStep(m0); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + // Step 5: fadingCount=3, acceptChance=0.8; random=0.9 → not accepted + solverScope.setWorkingRandom(new TestRandom(0.9)); + var stepScope5 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, m1))).isFalse(); + stepScope5.setStep(m0); + acceptor.stepEnded(stepScope5); + phaseScope.setLastCompletedStepScope(stepScope5); + + // Step 6: fadingCount=4, acceptChance=1.0; random not consumed but always true + // adjustTabuList removes m1 (tabuStepCount=6 ≥ totalTabuListSize=6) + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, m1))).isTrue(); + stepScope6.setStep(m0); + acceptor.stepEnded(stepScope6); + phaseScope.setLastCompletedStepScope(stepScope6); + + // Step 7: m1 expired, no random consumed + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope7 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, m1))).isTrue(); + + acceptor.phaseEnded(phaseScope); + } + + private static LocalSearchMoveScope buildMoveScope( + LocalSearchStepScope stepScope, Move move) { + var moveScope = new LocalSearchMoveScope<>(stepScope, 0, move); + moveScope.setInitializedScore(SimpleScore.of(0)); + return moveScope; + } + + private static LocalSearchMoveScope buildMoveScope( + LocalSearchStepScope stepScope, int score, Move move) { + var moveScope = new LocalSearchMoveScope<>(stepScope, 0, move); + moveScope.setInitializedScore(SimpleScore.of(score)); + return moveScope; + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/ValueTabuAcceptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/ValueTabuAcceptorTest.java index 4a29bc3cc47..f26f4c149a7 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/ValueTabuAcceptorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/ValueTabuAcceptorTest.java @@ -14,6 +14,7 @@ import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testutil.TestRandom; import org.junit.jupiter.api.Test; @@ -303,4 +304,79 @@ void unassignedPlanningValue() { acceptor.phaseEnded(phaseScope); } + @Test + void fadingTabuSize() { + var acceptor = new ValueTabuAcceptor<>(""); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2)); + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(4)); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + + var solverScope = new SolverScope<>(); + solverScope.setInitializedBestScore(SimpleScore.ZERO); + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + // Step 0: tabu v1 at stepIndex=0 + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + stepScope0.setStep(buildMoveScope(stepScope0, v1).getMove()); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + // Steps 1-2: hard tabu (tabuStepCount 1,2 ≤ 2) — no random consumed + var stepScope1 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, v1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, v0))).isTrue(); + stepScope1.setStep(buildMoveScope(stepScope1, v0).getMove()); + acceptor.stepEnded(stepScope1); + phaseScope.setLastCompletedStepScope(stepScope1); + + var stepScope2 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, v1))).isFalse(); + stepScope2.setStep(buildMoveScope(stepScope2, v0).getMove()); + acceptor.stepEnded(stepScope2); + phaseScope.setLastCompletedStepScope(stepScope2); + + // Step 3: fading zone, fadingCount=1, acceptChance=0.4; random=0.5 → rejected + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, v1))).isFalse(); + stepScope3.setStep(buildMoveScope(stepScope3, v0).getMove()); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + // Step 4: fading zone, fadingCount=2, acceptChance=0.6; random=0.5 → accepted + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, v1))).isTrue(); + stepScope4.setStep(buildMoveScope(stepScope4, v0).getMove()); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + // Step 5: fading zone, fadingCount=3, acceptChance=0.8; random=0.5 → accepted + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope5 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, v1))).isTrue(); + stepScope5.setStep(buildMoveScope(stepScope5, v0).getMove()); + acceptor.stepEnded(stepScope5); + phaseScope.setLastCompletedStepScope(stepScope5); + + // Step 6: fading zone, fadingCount=4, acceptChance=1.0; random not consumed and accepted + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, v1))).isTrue(); + stepScope6.setStep(buildMoveScope(stepScope6, v0).getMove()); + acceptor.stepEnded(stepScope6); + phaseScope.setLastCompletedStepScope(stepScope6); + + // Step 7: v1 expired, no random consumed + solverScope.setWorkingRandom(new TestRandom(new double[0])); + var stepScope7 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, v1))).isTrue(); + + acceptor.phaseEnded(phaseScope); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategyTest.java index a02c88385c4..2ca9f3cb49a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/size/FixedTabuSizeStrategyTest.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.size; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.Mockito.mock; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; @@ -16,4 +17,10 @@ void tabuSize() { assertThat(new FixedTabuSizeStrategy(17).determineTabuSize(stepScope)).isEqualTo(17); } + @Test + void invalidTabuSize() { + assertThatIllegalArgumentException().isThrownBy(() -> new FixedTabuSizeStrategy<>(0)); + assertThatIllegalArgumentException().isThrownBy(() -> new FixedTabuSizeStrategy<>(-1)); + } + }