From 4097b9d57ebbb5fc4091e83c47f36f4bf393e62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 11 Jun 2026 12:49:19 +0200 Subject: [PATCH 1/5] fix: only call RNG in Tabu search when necessary --- .../decider/acceptor/tabu/AbstractTabuAcceptor.java | 4 ++++ 1 file changed, 4 insertions(+) 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..2abb6745169 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 @@ -141,7 +141,11 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { logger.trace("{} Proposed move ({}) is tabu and is therefore not accepted.", logIndentation, moveScope.getMove()); return false; + } else if (workingFadingTabuSize == 0) { + // No comment as the move is not tabu; the tabu list needs to be adjusted. + return true; } + // From this point, we are guaranteed to be in a fading tabu. var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize); var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance; if (accepted) { From 1566de56314906725c031fdac1b04ae2a52f9972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 11 Jun 2026 14:20:04 +0200 Subject: [PATCH 2/5] fix: reject tabu size 0; validate VALUE/MOVE_TABU configs; remove dead else-if Co-Authored-By: Claude Sonnet 4.6 --- .../decider/acceptor/AcceptorFactory.java | 19 +++++++++++-------- .../acceptor/tabu/AbstractTabuAcceptor.java | 3 --- .../tabu/size/FixedTabuSizeStrategy.java | 6 +++--- .../decider/acceptor/AcceptorFactoryTest.java | 18 ++++++++++++++++++ .../tabu/size/FixedTabuSizeStrategyTest.java | 7 +++++++ 5 files changed, 39 insertions(+), 14 deletions(-) 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..536570c5f96 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 @@ -54,7 +54,7 @@ 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 { @@ -134,14 +134,12 @@ private Optional> buildEntityTabuAcceptor(Heuristi private Optional> buildValueTabuAcceptor(HeuristicConfigPolicy configPolicy) { 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.getValueTabuSize() == null && acceptorConfig.getFadingValueTabuSize() == null) { + throw new IllegalArgumentException( + "The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured." + .formatted(AcceptorType.VALUE_TABU)); } - if (acceptorConfig.getFadingValueTabuSize() != null) { - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingValueTabuSize())); - } - + var acceptor = new ValueTabuAcceptor(configPolicy.getLogIndentation()); if (acceptorConfig.getValueTabuSize() != null) { acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getValueTabuSize())); } @@ -159,6 +157,11 @@ private Optional> buildValueTabuAcceptor(HeuristicC private Optional> buildMoveTabuAcceptor(HeuristicConfigPolicy configPolicy) { if (acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU) || acceptorConfig.getMoveTabuSize() != null || acceptorConfig.getFadingMoveTabuSize() != null) { + if (acceptorConfig.getMoveTabuSize() == null && acceptorConfig.getFadingMoveTabuSize() == 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()); if (acceptorConfig.getMoveTabuSize() != null) { acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getMoveTabuSize())); 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 2abb6745169..ebcff5f1e4a 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 @@ -141,9 +141,6 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { logger.trace("{} Proposed move ({}) is tabu and is therefore not accepted.", logIndentation, moveScope.getMove()); return false; - } else if (workingFadingTabuSize == 0) { - // No comment as the move is not tabu; the tabu list needs to be adjusted. - return true; } // From this point, we are guaranteed to be in a fading tabu. var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize); 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/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)); + } + } From 4dbba25a41a532a4707b0efa92cbbd21ea1e8344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 11 Jun 2026 14:24:01 +0200 Subject: [PATCH 3/5] test: add fading tabu coverage; create MoveTabuAcceptorTest Co-Authored-By: Claude Sonnet 4.6 --- .../decider/acceptor/AcceptorFactory.java | 121 +++++----- .../acceptor/tabu/AbstractTabuAcceptor.java | 4 +- .../acceptor/tabu/EntityTabuAcceptorTest.java | 77 +++++++ .../acceptor/tabu/MoveTabuAcceptorTest.java | 213 ++++++++++++++++++ .../acceptor/tabu/ValueTabuAcceptorTest.java | 68 ++++++ 5 files changed, 423 insertions(+), 60 deletions(-) create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/MoveTabuAcceptorTest.java 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 536570c5f96..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; @@ -58,11 +59,10 @@ public Acceptor buildAcceptor(HeuristicConfigPolicy config } 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,46 +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) { - if (acceptorConfig.getValueTabuSize() == null && acceptorConfig.getFadingValueTabuSize() == null) { + || 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()); - 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); - } + 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) { - if (acceptorConfig.getMoveTabuSize() == null && acceptorConfig.getFadingMoveTabuSize() == null) { + || 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()); - 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); - } + configureFixedSizeTabuAcceptor(acceptor, configPolicy, moveTabuSize, fadingMoveTabuSize); return Optional.of(acceptor); } return Optional.empty(); @@ -184,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())); @@ -224,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 ebcff5f1e4a..76ede46bb30 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 @@ -144,7 +144,9 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { } // From this point, we are guaranteed to be in a fading tabu. var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize); - var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance; + // Only call RNG when necessary. + var accepted = Double.compare(acceptChance, 1.0d) >= 0 + || (Double.compare(acceptChance, 0.0d) > 0 && moveScope.getWorkingRandom().nextDouble() < acceptChance); if (accepted) { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is accepted.", logIndentation, 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..1a0f2ef2028 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.6; random=0.3 → accepted + solverScope.setWorkingRandom(new TestRandom(0.3)); + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, e1))).isTrue(); + stepScope3.setStep(buildMoveScope(stepScope3, e0).getMove()); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + // Step 4: fadingCount=2, acceptChance=0.4; random=0.5 → rejected + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, e1))).isFalse(); + stepScope4.setStep(buildMoveScope(stepScope4, e0).getMove()); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + // Step 5: fadingCount=3, acceptChance=0.2; 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=0.0; random consumed but always false + // adjustTabuList removes e1 (tabuStepCount=6 ≥ totalTabuListSize=6) + solverScope.setWorkingRandom(new TestRandom(0.99)); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, e1))).isFalse(); + 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..ed15098276f --- /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.6; 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.4; random=0.5 → rejected + solverScope.setWorkingRandom(new TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m1))).isFalse(); + stepScope4.setStep(m0); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + // Step 5: fadingCount=3, acceptChance=0.2; random=0.1 → accepted + solverScope.setWorkingRandom(new TestRandom(0.1)); + var stepScope5 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, m1))).isTrue(); + stepScope5.setStep(m0); + acceptor.stepEnded(stepScope5); + phaseScope.setLastCompletedStepScope(stepScope5); + + // Step 6: fadingCount=4, acceptChance=0.0; random consumed but always false + // adjustTabuList removes m1 (tabuStepCount=6 ≥ totalTabuListSize=6) + solverScope.setWorkingRandom(new TestRandom(0.99)); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, m1))).isFalse(); + 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..f5e16cfe72f 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 @@ -303,4 +303,72 @@ 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 ai.timefold.solver.core.testutil.TestRandom(new double[0])); + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + acceptor.phaseStarted(phaseScope); + + var stepScope0 = new LocalSearchStepScope<>(phaseScope); + stepScope0.setStep(buildMoveScope(stepScope0, v1).getMove()); + acceptor.stepEnded(stepScope0); + phaseScope.setLastCompletedStepScope(stepScope0); + + 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); + + solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.3)); + var stepScope3 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, v1))).isTrue(); + stepScope3.setStep(buildMoveScope(stepScope3, v0).getMove()); + acceptor.stepEnded(stepScope3); + phaseScope.setLastCompletedStepScope(stepScope3); + + solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.5)); + var stepScope4 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, v1))).isFalse(); + stepScope4.setStep(buildMoveScope(stepScope4, v0).getMove()); + acceptor.stepEnded(stepScope4); + phaseScope.setLastCompletedStepScope(stepScope4); + + solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.1)); + var stepScope5 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, v1))).isTrue(); + stepScope5.setStep(buildMoveScope(stepScope5, v0).getMove()); + acceptor.stepEnded(stepScope5); + phaseScope.setLastCompletedStepScope(stepScope5); + + solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.99)); + var stepScope6 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, v1))).isFalse(); + stepScope6.setStep(buildMoveScope(stepScope6, v0).getMove()); + acceptor.stepEnded(stepScope6); + phaseScope.setLastCompletedStepScope(stepScope6); + + solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(new double[0])); + var stepScope7 = new LocalSearchStepScope<>(phaseScope); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, v1))).isTrue(); + + acceptor.phaseEnded(phaseScope); + } + } From 31d5bb3ccd3af215a93c4f96ab8f2d7c0f5d6850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 12 Jun 2026 09:26:34 +0200 Subject: [PATCH 4/5] fix fading tabu --- .../acceptor/tabu/AbstractTabuAcceptor.java | 46 ++++++++++++------- .../acceptor/tabu/EntityTabuAcceptorTest.java | 14 +++--- .../acceptor/tabu/MoveTabuAcceptorTest.java | 18 ++++---- .../acceptor/tabu/ValueTabuAcceptorTest.java | 19 ++++---- 4 files changed, 56 insertions(+), 41 deletions(-) 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 76ede46bb30..57452fb7eb1 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,21 +142,15 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { logIndentation, moveScope.getMove()); return false; } - // From this point, we are guaranteed to be in a fading tabu. - var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize); - // Only call RNG when necessary. - var accepted = Double.compare(acceptChance, 1.0d) >= 0 - || (Double.compare(acceptChance, 0.0d) > 0 && moveScope.getWorkingRandom().nextDouble() < acceptChance); - if (accepted) { + var decision = decideFadingTabuAcceptance(moveScope, tabuStepCount - workingTabuSize); + if (decision.accepted()) { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is accepted.", - logIndentation, - moveScope.getMove(), acceptChance); + logIndentation, moveScope.getMove(), decision.acceptChance()); } else { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is not accepted.", - logIndentation, - moveScope.getMove(), acceptChance); + logIndentation, moveScope.getMove(), decision.acceptChance()); } - return accepted; + return decision.accepted(); } private int locateMaximumTabuStepIndex(LocalSearchMoveScope moveScope) { @@ -188,12 +182,32 @@ private int locateMaximumTabuStepIndex(LocalSearchMoveScope moveScope /** * @param fadingTabuStepCount {@code 0 < fadingTabuStepCount <= fadingTabuSize} - * @return {@code 0.0 < acceptChance < 1.0} + * @return a record with the accept chance, and the decision */ - 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 FadingTabuDecision 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 FadingTabuDecision.CERTAIN; + } + var denominator = workingFadingTabuSize + 1; + if (numerator >= denominator) { // The inverted chance would be <= 0. + return FadingTabuDecision.IMPOSSIBLE; + } + var acceptChance = 1.0d - (numerator / (double) denominator); + var accepted = Double.compare(moveScope.getWorkingRandom().nextDouble(), acceptChance) < 0; + return new FadingTabuDecision(acceptChance, accepted); + } + + /** + * @param acceptChance 0.0 <= acceptChance <= 1.0 + * @param accepted + */ + private record FadingTabuDecision(double acceptChance, boolean accepted) { + + private static final FadingTabuDecision CERTAIN = new FadingTabuDecision(1.0d, true); + private static final FadingTabuDecision IMPOSSIBLE = new FadingTabuDecision(0.0d, false); + } /** 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 1a0f2ef2028..1263e37955e 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 @@ -273,7 +273,7 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope2); phaseScope.setLastCompletedStepScope(stepScope2); - // Step 3: fading zone, fadingCount=1, acceptChance=0.6; random=0.3 → accepted + // Step 3: fading zone, 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, e1))).isTrue(); @@ -281,15 +281,15 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope3); phaseScope.setLastCompletedStepScope(stepScope3); - // Step 4: fadingCount=2, acceptChance=0.4; random=0.5 → rejected + // 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))).isFalse(); + 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.2; random=0.1 → accepted + // 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(); @@ -297,11 +297,11 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope5); phaseScope.setLastCompletedStepScope(stepScope5); - // Step 6: fadingCount=4, acceptChance=0.0; random consumed but always false + // Step 6: fadingCount=4, acceptChance=1.0; random not consumed and accepted // adjustTabuList removes e1 (tabuStepCount=6 ≥ totalTabuListSize=6) - solverScope.setWorkingRandom(new TestRandom(0.99)); + solverScope.setWorkingRandom(new TestRandom(new double[0])); var stepScope6 = new LocalSearchStepScope<>(phaseScope); - assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, e1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, e1))).isTrue(); stepScope6.setStep(buildMoveScope(stepScope6, e0).getMove()); acceptor.stepEnded(stepScope6); phaseScope.setLastCompletedStepScope(stepScope6); 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 index ed15098276f..def246dab6f 100644 --- 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 @@ -155,7 +155,7 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope2); phaseScope.setLastCompletedStepScope(stepScope2); - // Step 3: fadingCount=1, acceptChance=0.6; random=0.3 → accepted + // 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(); @@ -163,27 +163,27 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope3); phaseScope.setLastCompletedStepScope(stepScope3); - // Step 4: fadingCount=2, acceptChance=0.4; random=0.5 → rejected + // 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))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, m1))).isTrue(); stepScope4.setStep(m0); acceptor.stepEnded(stepScope4); phaseScope.setLastCompletedStepScope(stepScope4); - // Step 5: fadingCount=3, acceptChance=0.2; random=0.1 → accepted - solverScope.setWorkingRandom(new TestRandom(0.1)); + // 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))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, m1))).isFalse(); stepScope5.setStep(m0); acceptor.stepEnded(stepScope5); phaseScope.setLastCompletedStepScope(stepScope5); - // Step 6: fadingCount=4, acceptChance=0.0; random consumed but always false + // Step 6: fadingCount=4, acceptChance=1.0; random not consumed but always true // adjustTabuList removes m1 (tabuStepCount=6 ≥ totalTabuListSize=6) - solverScope.setWorkingRandom(new TestRandom(0.99)); + solverScope.setWorkingRandom(new TestRandom(new double[0])); var stepScope6 = new LocalSearchStepScope<>(phaseScope); - assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, m1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, m1))).isTrue(); stepScope6.setStep(m0); acceptor.stepEnded(stepScope6); phaseScope.setLastCompletedStepScope(stepScope6); 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 f5e16cfe72f..928d4228618 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; @@ -314,7 +315,7 @@ void fadingTabuSize() { var solverScope = new SolverScope<>(); solverScope.setInitializedBestScore(SimpleScore.ZERO); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(new double[0])); + solverScope.setWorkingRandom(new TestRandom(new double[0])); var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); acceptor.phaseStarted(phaseScope); @@ -336,35 +337,35 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope2); phaseScope.setLastCompletedStepScope(stepScope2); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.3)); + solverScope.setWorkingRandom(new TestRandom(0.3)); var stepScope3 = new LocalSearchStepScope<>(phaseScope); assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, v1))).isTrue(); stepScope3.setStep(buildMoveScope(stepScope3, v0).getMove()); acceptor.stepEnded(stepScope3); phaseScope.setLastCompletedStepScope(stepScope3); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.5)); + solverScope.setWorkingRandom(new TestRandom(0.5)); var stepScope4 = new LocalSearchStepScope<>(phaseScope); - assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, v1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, v1))).isTrue(); stepScope4.setStep(buildMoveScope(stepScope4, v0).getMove()); acceptor.stepEnded(stepScope4); phaseScope.setLastCompletedStepScope(stepScope4); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.1)); + solverScope.setWorkingRandom(new TestRandom(0.99)); var stepScope5 = new LocalSearchStepScope<>(phaseScope); - assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, v1))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, v1))).isFalse(); stepScope5.setStep(buildMoveScope(stepScope5, v0).getMove()); acceptor.stepEnded(stepScope5); phaseScope.setLastCompletedStepScope(stepScope5); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(0.99)); + solverScope.setWorkingRandom(new TestRandom(new double[0])); var stepScope6 = new LocalSearchStepScope<>(phaseScope); - assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, v1))).isFalse(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, v1))).isTrue(); stepScope6.setStep(buildMoveScope(stepScope6, v0).getMove()); acceptor.stepEnded(stepScope6); phaseScope.setLastCompletedStepScope(stepScope6); - solverScope.setWorkingRandom(new ai.timefold.solver.core.testutil.TestRandom(new double[0])); + solverScope.setWorkingRandom(new TestRandom(new double[0])); var stepScope7 = new LocalSearchStepScope<>(phaseScope); assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, v1))).isTrue(); From 1ba6d6b48ce0080add6239d02de46664cec3f37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 13 Jun 2026 06:57:05 +0200 Subject: [PATCH 5/5] finishing touches --- .../acceptor/tabu/AbstractTabuAcceptor.java | 35 ++++++++----------- .../acceptor/tabu/EntityTabuAcceptorTest.java | 6 ++-- .../acceptor/tabu/ValueTabuAcceptorTest.java | 15 +++++--- 3 files changed, 28 insertions(+), 28 deletions(-) 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 57452fb7eb1..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 @@ -143,14 +143,15 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { return false; } var decision = decideFadingTabuAcceptance(moveScope, tabuStepCount - workingTabuSize); - if (decision.accepted()) { + var accepted = decision > 0; + if (accepted) { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is accepted.", - logIndentation, moveScope.getMove(), decision.acceptChance()); + logIndentation, moveScope.getMove(), Math.abs(decision)); } else { logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is not accepted.", - logIndentation, moveScope.getMove(), decision.acceptChance()); + logIndentation, moveScope.getMove(), Math.abs(decision)); } - return decision.accepted(); + return accepted; } private int locateMaximumTabuStepIndex(LocalSearchMoveScope moveScope) { @@ -182,32 +183,24 @@ private int locateMaximumTabuStepIndex(LocalSearchMoveScope moveScope /** * @param fadingTabuStepCount {@code 0 < fadingTabuStepCount <= fadingTabuSize} - * @return a record with the accept chance, and the decision + * @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. */ - private FadingTabuDecision decideFadingTabuAcceptance(LocalSearchMoveScope moveScope, int fadingTabuStepCount) { + 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 FadingTabuDecision.CERTAIN; + return 1.0d; } var denominator = workingFadingTabuSize + 1; if (numerator >= denominator) { // The inverted chance would be <= 0. - return FadingTabuDecision.IMPOSSIBLE; + return 0.0d; } var acceptChance = 1.0d - (numerator / (double) denominator); - var accepted = Double.compare(moveScope.getWorkingRandom().nextDouble(), acceptChance) < 0; - return new FadingTabuDecision(acceptChance, accepted); - } - - /** - * @param acceptChance 0.0 <= acceptChance <= 1.0 - * @param accepted - */ - private record FadingTabuDecision(double acceptChance, boolean accepted) { - - private static final FadingTabuDecision CERTAIN = new FadingTabuDecision(1.0d, true); - private static final FadingTabuDecision IMPOSSIBLE = new FadingTabuDecision(0.0d, false); - + var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance; + return accepted ? acceptChance : -acceptChance; } /** 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 1263e37955e..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 @@ -273,10 +273,10 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope2); phaseScope.setLastCompletedStepScope(stepScope2); - // Step 3: fading zone, fadingCount=1, acceptChance=0.4; random=0.3 → accepted - solverScope.setWorkingRandom(new TestRandom(0.3)); + // 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))).isTrue(); + assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, e1))).isFalse(); stepScope3.setStep(buildMoveScope(stepScope3, e0).getMove()); acceptor.stepEnded(stepScope3); phaseScope.setLastCompletedStepScope(stepScope3); 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 928d4228618..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 @@ -319,11 +319,13 @@ void fadingTabuSize() { 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(); @@ -337,13 +339,15 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope2); phaseScope.setLastCompletedStepScope(stepScope2); - solverScope.setWorkingRandom(new TestRandom(0.3)); + // 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))).isTrue(); + 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(); @@ -351,13 +355,15 @@ void fadingTabuSize() { acceptor.stepEnded(stepScope4); phaseScope.setLastCompletedStepScope(stepScope4); - solverScope.setWorkingRandom(new TestRandom(0.99)); + // 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))).isFalse(); + 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(); @@ -365,6 +371,7 @@ void fadingTabuSize() { 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();