Skip to content

Commit 4dbba25

Browse files
triceoclaude
andcommitted
test: add fading tabu coverage; create MoveTabuAcceptorTest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1566de5 commit 4dbba25

5 files changed

Lines changed: 423 additions & 60 deletions

File tree

core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance.LateAcceptanceAcceptor;
1818
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.simulatedannealing.SimulatedAnnealingAcceptor;
1919
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.stepcountinghillclimbing.StepCountingHillClimbingAcceptor;
20+
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.AbstractTabuAcceptor;
2021
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.EntityTabuAcceptor;
2122
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.MoveTabuAcceptor;
2223
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.ValueTabuAcceptor;
@@ -58,11 +59,10 @@ public Acceptor<Solution_> buildAcceptor(HeuristicConfigPolicy<Solution_> config
5859
} else if (acceptorList.size() > 1) {
5960
return new CompositeAcceptor<>(acceptorList);
6061
} else {
61-
throw new IllegalArgumentException(
62-
"The acceptor does not specify any acceptorType (" + acceptorConfig.getAcceptorTypeList()
63-
+ ") or other acceptor property.\n"
64-
+ "For a good starting values,"
65-
+ " see the docs section \"Which optimization algorithms should I use?\".");
62+
throw new IllegalArgumentException("""
63+
The acceptor does not specify any acceptorType (%s) or other acceptor property.
64+
For good starting values, see the docs section "Which optimization algorithms should I use?"."""
65+
.formatted(acceptorConfig.getAcceptorTypeList()));
6666
}
6767
}
6868

@@ -94,34 +94,35 @@ private Optional<StepCountingHillClimbingAcceptor<Solution_>> buildStepCountingH
9494
}
9595

9696
private Optional<EntityTabuAcceptor<Solution_>> buildEntityTabuAcceptor(HeuristicConfigPolicy<Solution_> configPolicy) {
97+
var entityTabuSize = acceptorConfig.getEntityTabuSize();
98+
var entityTabuRatio = acceptorConfig.getEntityTabuRatio();
99+
var fadingEntityTabuSize = acceptorConfig.getFadingEntityTabuSize();
100+
var fadingEntityTabuRatio = acceptorConfig.getFadingEntityTabuRatio();
97101
if (acceptorTypeListsContainsAcceptorType(AcceptorType.ENTITY_TABU)
98-
|| acceptorConfig.getEntityTabuSize() != null || acceptorConfig.getEntityTabuRatio() != null
99-
|| acceptorConfig.getFadingEntityTabuSize() != null || acceptorConfig.getFadingEntityTabuRatio() != null) {
102+
|| entityTabuSize != null || entityTabuRatio != null
103+
|| fadingEntityTabuSize != null || fadingEntityTabuRatio != null) {
100104
var acceptor = new EntityTabuAcceptor<Solution_>(configPolicy.getLogIndentation());
101-
if (acceptorConfig.getEntityTabuSize() != null) {
102-
if (acceptorConfig.getEntityTabuRatio() != null) {
103-
throw new IllegalArgumentException("The acceptor cannot have both acceptorConfig.getEntityTabuSize() ("
104-
+ acceptorConfig.getEntityTabuSize() + ") and acceptorConfig.getEntityTabuRatio() ("
105-
+ acceptorConfig.getEntityTabuRatio() + ").");
105+
if (entityTabuSize != null) {
106+
if (entityTabuRatio != null) {
107+
throw new IllegalArgumentException(
108+
"The acceptor cannot have both entityTabuSize (%d) and entityTabuRatio (%f)."
109+
.formatted(entityTabuSize, entityTabuRatio));
106110
}
107-
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getEntityTabuSize()));
108-
} else if (acceptorConfig.getEntityTabuRatio() != null) {
109-
acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(acceptorConfig.getEntityTabuRatio()));
110-
} else if (acceptorConfig.getFadingEntityTabuSize() == null && acceptorConfig.getFadingEntityTabuRatio() == null) {
111+
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(entityTabuSize));
112+
} else if (entityTabuRatio != null) {
113+
acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(entityTabuRatio));
114+
} else if (fadingEntityTabuSize == null && fadingEntityTabuRatio == null) {
111115
acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(0.1));
112116
}
113-
if (acceptorConfig.getFadingEntityTabuSize() != null) {
114-
if (acceptorConfig.getFadingEntityTabuRatio() != null) {
117+
if (fadingEntityTabuSize != null) {
118+
if (fadingEntityTabuRatio != null) {
115119
throw new IllegalArgumentException(
116-
"The acceptor cannot have both acceptorConfig.getFadingEntityTabuSize() ("
117-
+ acceptorConfig.getFadingEntityTabuSize()
118-
+ ") and acceptorConfig.getFadingEntityTabuRatio() ("
119-
+ acceptorConfig.getFadingEntityTabuRatio() + ").");
120+
"The acceptor cannot have both fadingEntityTabuSize (%d) and fadingEntityTabuRatio (%f)."
121+
.formatted(fadingEntityTabuSize, fadingEntityTabuRatio));
120122
}
121-
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingEntityTabuSize()));
122-
} else if (acceptorConfig.getFadingEntityTabuRatio() != null) {
123-
acceptor.setFadingTabuSizeStrategy(
124-
new EntityRatioTabuSizeStrategy<>(acceptorConfig.getFadingEntityTabuRatio()));
123+
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingEntityTabuSize));
124+
} else if (fadingEntityTabuRatio != null) {
125+
acceptor.setFadingTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(fadingEntityTabuRatio));
125126
}
126127
if (configPolicy.getEnvironmentMode().isFullyAsserted()) {
127128
acceptor.setAssertTabuHashCodeCorrectness(true);
@@ -132,46 +133,47 @@ private Optional<EntityTabuAcceptor<Solution_>> buildEntityTabuAcceptor(Heuristi
132133
}
133134

134135
private Optional<ValueTabuAcceptor<Solution_>> buildValueTabuAcceptor(HeuristicConfigPolicy<Solution_> configPolicy) {
136+
var valueTabuSize = acceptorConfig.getValueTabuSize();
137+
var fadingValueTabuSize = acceptorConfig.getFadingValueTabuSize();
135138
if (acceptorTypeListsContainsAcceptorType(AcceptorType.VALUE_TABU)
136-
|| acceptorConfig.getValueTabuSize() != null || acceptorConfig.getFadingValueTabuSize() != null) {
137-
if (acceptorConfig.getValueTabuSize() == null && acceptorConfig.getFadingValueTabuSize() == null) {
139+
|| valueTabuSize != null || fadingValueTabuSize != null) {
140+
if (valueTabuSize == null && fadingValueTabuSize == null) {
138141
throw new IllegalArgumentException(
139142
"The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured."
140143
.formatted(AcceptorType.VALUE_TABU));
141144
}
142145
var acceptor = new ValueTabuAcceptor<Solution_>(configPolicy.getLogIndentation());
143-
if (acceptorConfig.getValueTabuSize() != null) {
144-
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getValueTabuSize()));
145-
}
146-
if (acceptorConfig.getFadingValueTabuSize() != null) {
147-
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingValueTabuSize()));
148-
}
149-
if (configPolicy.getEnvironmentMode().isFullyAsserted()) {
150-
acceptor.setAssertTabuHashCodeCorrectness(true);
151-
}
146+
configureFixedSizeTabuAcceptor(acceptor, configPolicy, valueTabuSize, fadingValueTabuSize);
152147
return Optional.of(acceptor);
153148
}
154149
return Optional.empty();
155150
}
156151

152+
private static <Solution_> void configureFixedSizeTabuAcceptor(AbstractTabuAcceptor<Solution_> acceptor,
153+
HeuristicConfigPolicy<Solution_> configPolicy, Integer tabuSize, Integer fadingTabuSize) {
154+
if (tabuSize != null) {
155+
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(tabuSize));
156+
}
157+
if (fadingTabuSize != null) {
158+
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingTabuSize));
159+
}
160+
if (configPolicy.getEnvironmentMode().isFullyAsserted()) {
161+
acceptor.setAssertTabuHashCodeCorrectness(true);
162+
}
163+
}
164+
157165
private Optional<MoveTabuAcceptor<Solution_>> buildMoveTabuAcceptor(HeuristicConfigPolicy<Solution_> configPolicy) {
166+
var moveTabuSize = acceptorConfig.getMoveTabuSize();
167+
var fadingMoveTabuSize = acceptorConfig.getFadingMoveTabuSize();
158168
if (acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU)
159-
|| acceptorConfig.getMoveTabuSize() != null || acceptorConfig.getFadingMoveTabuSize() != null) {
160-
if (acceptorConfig.getMoveTabuSize() == null && acceptorConfig.getFadingMoveTabuSize() == null) {
169+
|| moveTabuSize != null || fadingMoveTabuSize != null) {
170+
if (moveTabuSize == null && fadingMoveTabuSize == null) {
161171
throw new IllegalArgumentException(
162172
"The acceptorType (%s) requires either moveTabuSize or fadingMoveTabuSize to be configured."
163173
.formatted(AcceptorType.MOVE_TABU));
164174
}
165175
var acceptor = new MoveTabuAcceptor<Solution_>(configPolicy.getLogIndentation());
166-
if (acceptorConfig.getMoveTabuSize() != null) {
167-
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getMoveTabuSize()));
168-
}
169-
if (acceptorConfig.getFadingMoveTabuSize() != null) {
170-
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(acceptorConfig.getFadingMoveTabuSize()));
171-
}
172-
if (configPolicy.getEnvironmentMode().isFullyAsserted()) {
173-
acceptor.setAssertTabuHashCodeCorrectness(true);
174-
}
176+
configureFixedSizeTabuAcceptor(acceptor, configPolicy, moveTabuSize, fadingMoveTabuSize);
175177
return Optional.of(acceptor);
176178
}
177179
return Optional.empty();
@@ -184,9 +186,9 @@ private Optional<MoveTabuAcceptor<Solution_>> buildMoveTabuAcceptor(HeuristicCon
184186
var acceptor = new SimulatedAnnealingAcceptor<Solution_>();
185187
if (acceptorConfig.getSimulatedAnnealingStartingTemperature() == null) {
186188
// TODO Support SA without a parameter
187-
throw new IllegalArgumentException("The acceptorType (" + AcceptorType.SIMULATED_ANNEALING
188-
+ ") currently requires a acceptorConfig.getSimulatedAnnealingStartingTemperature() ("
189-
+ acceptorConfig.getSimulatedAnnealingStartingTemperature() + ").");
189+
throw new IllegalArgumentException(
190+
"The acceptorType (%s) requires non-null acceptorConfig.getSimulatedAnnealingStartingTemperature()."
191+
.formatted(AcceptorType.SIMULATED_ANNEALING));
190192
}
191193
acceptor.setStartingTemperature(
192194
configPolicy.getScoreDefinition().parseScore(acceptorConfig.getSimulatedAnnealingStartingTemperature()));
@@ -224,19 +226,20 @@ private Optional<GreatDelugeAcceptor<Solution_>> buildGreatDelugeAcceptor(Heuris
224226
var acceptor = new GreatDelugeAcceptor<Solution_>();
225227
if (acceptorConfig.getGreatDelugeWaterLevelIncrementScore() != null) {
226228
if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) {
227-
throw new IllegalArgumentException("The acceptor cannot have both a "
228-
+ "acceptorConfig.getGreatDelugeWaterLevelIncrementScore() ("
229-
+ acceptorConfig.getGreatDelugeWaterLevelIncrementScore()
230-
+ ") and a acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() ("
231-
+ acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() + ").");
229+
throw new IllegalArgumentException("""
230+
The acceptor cannot have both acceptorConfig.getGreatDelugeWaterLevelIncrementScore() (%s) \
231+
and acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s)."""
232+
.formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementScore(),
233+
acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()));
232234
}
233235
acceptor.setWaterLevelIncrementScore(
234236
configPolicy.getScoreDefinition().parseScore(acceptorConfig.getGreatDelugeWaterLevelIncrementScore()));
235237
} else if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) {
236238
if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() <= 0.0) {
237-
throw new IllegalArgumentException("The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() ("
238-
+ acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()
239-
+ ") must be positive because the water level should increase.");
239+
throw new IllegalArgumentException("""
240+
The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s) must be positive \
241+
because the water level should increase."""
242+
.formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()));
240243
}
241244
acceptor.setWaterLevelIncrementRatio(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio());
242245
} else {

core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ public boolean isAccepted(LocalSearchMoveScope<Solution_> moveScope) {
144144
}
145145
// From this point, we are guaranteed to be in a fading tabu.
146146
var acceptChance = calculateFadingTabuAcceptChance(tabuStepCount - workingTabuSize);
147-
var accepted = moveScope.getWorkingRandom().nextDouble() < acceptChance;
147+
// Only call RNG when necessary.
148+
var accepted = Double.compare(acceptChance, 1.0d) >= 0
149+
|| (Double.compare(acceptChance, 0.0d) > 0 && moveScope.getWorkingRandom().nextDouble() < acceptChance);
148150
if (accepted) {
149151
logger.trace("{} Proposed move ({}) is fading tabu with acceptChance ({}) and is accepted.",
150152
logIndentation,

core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/EntityTabuAcceptorTest.java

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
1515
import ai.timefold.solver.core.preview.api.move.Move;
1616
import ai.timefold.solver.core.testdomain.TestdataEntity;
17+
import ai.timefold.solver.core.testutil.TestRandom;
1718

1819
import org.junit.jupiter.api.Test;
1920

@@ -237,6 +238,82 @@ void aspiration() {
237238
acceptor.phaseEnded(phaseScope);
238239
}
239240

241+
@Test
242+
void fadingTabuSize() {
243+
var acceptor = new EntityTabuAcceptor<>("");
244+
acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(2));
245+
acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(4));
246+
247+
var e0 = new TestdataEntity("e0");
248+
var e1 = new TestdataEntity("e1");
249+
250+
var solverScope = new SolverScope<>();
251+
solverScope.setInitializedBestScore(SimpleScore.ZERO);
252+
solverScope.setWorkingRandom(new TestRandom(new double[0]));
253+
var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0);
254+
acceptor.phaseStarted(phaseScope);
255+
256+
// Step 0: tabu e1 at stepIndex=0
257+
var stepScope0 = new LocalSearchStepScope<>(phaseScope);
258+
stepScope0.setStep(buildMoveScope(stepScope0, e1).getMove());
259+
acceptor.stepEnded(stepScope0);
260+
phaseScope.setLastCompletedStepScope(stepScope0);
261+
262+
// Steps 1-2: hard tabu (tabuStepCount 1,2 ≤ 2) — no random consumed
263+
var stepScope1 = new LocalSearchStepScope<>(phaseScope);
264+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, e1))).isFalse();
265+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope1, e0))).isTrue();
266+
stepScope1.setStep(buildMoveScope(stepScope1, e0).getMove());
267+
acceptor.stepEnded(stepScope1);
268+
phaseScope.setLastCompletedStepScope(stepScope1);
269+
270+
var stepScope2 = new LocalSearchStepScope<>(phaseScope);
271+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope2, e1))).isFalse();
272+
stepScope2.setStep(buildMoveScope(stepScope2, e0).getMove());
273+
acceptor.stepEnded(stepScope2);
274+
phaseScope.setLastCompletedStepScope(stepScope2);
275+
276+
// Step 3: fading zone, fadingCount=1, acceptChance=0.6; random=0.3 → accepted
277+
solverScope.setWorkingRandom(new TestRandom(0.3));
278+
var stepScope3 = new LocalSearchStepScope<>(phaseScope);
279+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope3, e1))).isTrue();
280+
stepScope3.setStep(buildMoveScope(stepScope3, e0).getMove());
281+
acceptor.stepEnded(stepScope3);
282+
phaseScope.setLastCompletedStepScope(stepScope3);
283+
284+
// Step 4: fadingCount=2, acceptChance=0.4; random=0.5 → rejected
285+
solverScope.setWorkingRandom(new TestRandom(0.5));
286+
var stepScope4 = new LocalSearchStepScope<>(phaseScope);
287+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope4, e1))).isFalse();
288+
stepScope4.setStep(buildMoveScope(stepScope4, e0).getMove());
289+
acceptor.stepEnded(stepScope4);
290+
phaseScope.setLastCompletedStepScope(stepScope4);
291+
292+
// Step 5: fadingCount=3, acceptChance=0.2; random=0.1 → accepted
293+
solverScope.setWorkingRandom(new TestRandom(0.1));
294+
var stepScope5 = new LocalSearchStepScope<>(phaseScope);
295+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope5, e1))).isTrue();
296+
stepScope5.setStep(buildMoveScope(stepScope5, e0).getMove());
297+
acceptor.stepEnded(stepScope5);
298+
phaseScope.setLastCompletedStepScope(stepScope5);
299+
300+
// Step 6: fadingCount=4, acceptChance=0.0; random consumed but always false
301+
// adjustTabuList removes e1 (tabuStepCount=6 ≥ totalTabuListSize=6)
302+
solverScope.setWorkingRandom(new TestRandom(0.99));
303+
var stepScope6 = new LocalSearchStepScope<>(phaseScope);
304+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope6, e1))).isFalse();
305+
stepScope6.setStep(buildMoveScope(stepScope6, e0).getMove());
306+
acceptor.stepEnded(stepScope6);
307+
phaseScope.setLastCompletedStepScope(stepScope6);
308+
309+
// Step 7: e1 expired, no random consumed
310+
solverScope.setWorkingRandom(new TestRandom(new double[0]));
311+
var stepScope7 = new LocalSearchStepScope<>(phaseScope);
312+
assertThat(acceptor.isAccepted(buildMoveScope(stepScope7, e1))).isTrue();
313+
314+
acceptor.phaseEnded(phaseScope);
315+
}
316+
240317
private static <Solution_> LocalSearchMoveScope<Solution_> buildMoveScope(LocalSearchStepScope<Solution_> stepScope,
241318
TestdataEntity... entities) {
242319
return buildMoveScope(stepScope, 0, entities);

0 commit comments

Comments
 (0)