Skip to content

Commit b35c255

Browse files
authored
fix: reset late list when upper levels of score changed (TimefoldAI#2407)
This has been shown to prevent exceptional situations where the solver would flatline immediately after start for a period of time determined by the LA list size, and in general dominates the previous late acceptor.
1 parent 208d5b5 commit b35c255

9 files changed

Lines changed: 598 additions & 18 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance;
2+
3+
import ai.timefold.solver.core.api.score.IBendableScore;
4+
import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope;
5+
import ai.timefold.solver.core.impl.score.definition.ScoreDefinition;
6+
import ai.timefold.solver.core.impl.score.director.InnerScore;
7+
8+
/**
9+
* Default implementation of {@link LevelScoreState} for scores with more than one level.
10+
* <p>
11+
* Caches the best-solution step index and the corresponding score level values.
12+
* On each step, {@link #update} refreshes the cache when the best solution has changed,
13+
* and {@link #isNonDominatedLevelChanged} compares the non-dominated levels
14+
* (hard for {@link IBendableScore IBendableScore}, hard and medium for all others)
15+
* against the cached values to determine whether the {@link LateAcceptanceScoreBuffer} should be reset.
16+
*/
17+
final class DefaultLevelScoreState<Solution_> implements LevelScoreState<Solution_> {
18+
19+
private final int nonDominatedLevelCount;
20+
private long previousBestScoreIndex;
21+
private Number[] previousBestScoreLevels;
22+
23+
@SuppressWarnings("rawtypes")
24+
DefaultLevelScoreState(InnerScore initialScore, ScoreDefinition scoreDefinition) {
25+
previousBestScoreLevels = initialScore.raw().toLevelNumbers();
26+
if (IBendableScore.class.isAssignableFrom(scoreDefinition.getScoreClass())) {
27+
// We only evaluate the hard score levels
28+
nonDominatedLevelCount = scoreDefinition.getFeasibleLevelsSize();
29+
} else {
30+
// We only evaluate the hard or medium levels
31+
nonDominatedLevelCount = scoreDefinition.getLevelsSize() - 1;
32+
}
33+
}
34+
35+
@Override
36+
public void update(LocalSearchStepScope<Solution_> stepScope) {
37+
var phaseScope = stepScope.getPhaseScope();
38+
var bestSolutionStepIndex = phaseScope.getBestSolutionStepIndex();
39+
if (previousBestScoreIndex != bestSolutionStepIndex) {
40+
// Update the current best score information
41+
this.previousBestScoreIndex = bestSolutionStepIndex;
42+
this.previousBestScoreLevels = stepScope.getPhaseScope().getBestScore().raw().toLevelNumbers();
43+
}
44+
}
45+
46+
/**
47+
* If non-dominated levels are updated (hard or medium), it is necessary to reset the late scores.
48+
* Failing to do so may cause the solver
49+
* to accept poor moves that do not affect the non-dominated scores but degrade the soft scores.
50+
* As a result,
51+
* any move that does not decrease the hard or medium score
52+
* but significantly worsens the soft score may be mistakenly accepted.
53+
* This could cause the working solution
54+
* to enter a bad region and require many additional steps to escape it.
55+
*
56+
* @return true if any non-dominated score has changed; otherwise, returns false
57+
*/
58+
@Override
59+
public boolean isNonDominatedLevelChanged(LocalSearchStepScope<Solution_> stepScope) {
60+
var phaseScope = stepScope.getPhaseScope();
61+
var bestSolutionStepIndex = phaseScope.getBestSolutionStepIndex();
62+
if (previousBestScoreIndex != bestSolutionStepIndex) {
63+
var newBestScore = stepScope.getPhaseScope().getBestScore();
64+
var newBestScoreLevels = newBestScore.raw().toLevelNumbers();
65+
for (var i = 0; i < nonDominatedLevelCount; i++) {
66+
if (!newBestScoreLevels[i].equals(previousBestScoreLevels[i])) {
67+
return true;
68+
}
69+
}
70+
}
71+
return false;
72+
}
73+
}

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

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance;
22

3-
import java.util.Arrays;
4-
53
import ai.timefold.solver.core.api.score.Score;
64
import ai.timefold.solver.core.impl.localsearch.decider.acceptor.AbstractAcceptor;
75
import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchMoveScope;
@@ -14,8 +12,8 @@ public class LateAcceptanceAcceptor<Solution_> extends AbstractAcceptor<Solution
1412
protected int lateAcceptanceSize = -1;
1513
protected boolean hillClimbingEnabled = true;
1614

17-
protected InnerScore<?>[] previousScores;
18-
protected int lateScoreIndex = -1;
15+
private LateAcceptanceScoreBuffer scoreBuffer;
16+
private LevelScoreState<Solution_> bestScoreState;
1917

2018
public void setLateAcceptanceSize(int lateAcceptanceSize) {
2119
this.lateAcceptanceSize = lateAcceptanceSize;
@@ -33,10 +31,11 @@ public void setHillClimbingEnabled(boolean hillClimbingEnabled) {
3331
public void phaseStarted(LocalSearchPhaseScope<Solution_> phaseScope) {
3432
super.phaseStarted(phaseScope);
3533
validate();
36-
previousScores = new InnerScore[lateAcceptanceSize];
3734
var initialScore = phaseScope.getBestScore();
38-
Arrays.fill(previousScores, initialScore);
39-
lateScoreIndex = 0;
35+
scoreBuffer = new LateAcceptanceScoreBuffer(lateAcceptanceSize, initialScore);
36+
var scoreDefinition = phaseScope.getSolverScope().getScoreDefinition();
37+
bestScoreState = scoreDefinition.getLevelsSize() > 1 ? new DefaultLevelScoreState<>(initialScore, scoreDefinition)
38+
: new NoOpLevelScoreState<>();
4039
}
4140

4241
private void validate() {
@@ -50,7 +49,7 @@ private void validate() {
5049
@Override
5150
public boolean isAccepted(LocalSearchMoveScope<Solution_> moveScope) {
5251
var moveScore = (InnerScore) moveScope.getScore();
53-
var lateScore = getPreviousScore(lateScoreIndex);
52+
var lateScore = scoreBuffer.getCurrent();
5453
if (moveScore.compareTo(lateScore) >= 0) {
5554
return true;
5655
}
@@ -62,23 +61,30 @@ public boolean isAccepted(LocalSearchMoveScope<Solution_> moveScope) {
6261
return false;
6362
}
6463

65-
@SuppressWarnings("unchecked")
66-
private <Score_ extends Score<Score_>> InnerScore<Score_> getPreviousScore(int lateScoreIndex) {
67-
return (InnerScore<Score_>) previousScores[lateScoreIndex];
64+
@Override
65+
public void stepStarted(LocalSearchStepScope<Solution_> stepScope) {
66+
super.stepStarted(stepScope);
67+
bestScoreState.update(stepScope);
6868
}
6969

7070
@Override
7171
public void stepEnded(LocalSearchStepScope<Solution_> stepScope) {
7272
super.stepEnded(stepScope);
73-
previousScores[lateScoreIndex] = stepScope.getScore();
74-
lateScoreIndex = (lateScoreIndex + 1) % lateAcceptanceSize;
73+
scoreBuffer.update(stepScope.getScore());
74+
if (bestScoreState.isNonDominatedLevelChanged(stepScope)) {
75+
scoreBuffer.tryReset(stepScope.getPhaseScope().getBestScore());
76+
}
7577
}
7678

7779
@Override
7880
public void phaseEnded(LocalSearchPhaseScope<Solution_> phaseScope) {
7981
super.phaseEnded(phaseScope);
80-
previousScores = null;
81-
lateScoreIndex = -1;
82+
scoreBuffer = null;
83+
bestScoreState = null;
84+
}
85+
86+
protected <Score_ extends Score<Score_>> InnerScore<Score_> getScore(int i) {
87+
return scoreBuffer.get(i);
8288
}
8389

8490
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance;
2+
3+
import java.util.Arrays;
4+
import java.util.Objects;
5+
6+
import ai.timefold.solver.core.api.score.Score;
7+
import ai.timefold.solver.core.impl.score.director.InnerScore;
8+
9+
/**
10+
* Circular buffer implementation for managing late scores,
11+
* enabling simpler reset logic.
12+
* When {@link #tryReset} is called,
13+
* instead of filling all slots,
14+
* an epoch counter is incremented,
15+
* allowing the action to avoid reloading the score array with the new score.
16+
*/
17+
final class LateAcceptanceScoreBuffer {
18+
19+
// Late score fields
20+
private final InnerScore<?>[] scores;
21+
private int currentIndex = 0;
22+
private final int size;
23+
// All required epoch fields
24+
private final long[] slotEpoch;
25+
private long resetEpoch = 0;
26+
private InnerScore<?> resetScore = null;
27+
private boolean writtenSinceReset = false;
28+
29+
LateAcceptanceScoreBuffer(int size, InnerScore<?> initialScore) {
30+
this.scores = new InnerScore[size];
31+
Arrays.fill(scores, initialScore);
32+
this.size = size;
33+
// By default,
34+
// the score is set to zero,
35+
// and it means all scores will be read initially.
36+
this.slotEpoch = new long[size];
37+
}
38+
39+
<Score_ extends Score<Score_>> InnerScore<Score_> getCurrent() {
40+
return get(currentIndex);
41+
}
42+
43+
@SuppressWarnings("unchecked")
44+
<Score_ extends Score<Score_>> InnerScore<Score_> get(int index) {
45+
if (slotEpoch[index] < resetEpoch) {
46+
return (InnerScore<Score_>) resetScore;
47+
}
48+
return (InnerScore<Score_>) scores[index];
49+
}
50+
51+
/**
52+
* Update the score and advance the current late index.
53+
*
54+
* @param score the score to be added to the buffer
55+
*/
56+
void update(InnerScore<?> score) {
57+
scores[currentIndex] = score;
58+
slotEpoch[currentIndex] = resetEpoch;
59+
writtenSinceReset = true;
60+
currentIndex = (currentIndex + 1) % size;
61+
}
62+
63+
/**
64+
* Lazily resets all slots to {@code newScore}.
65+
* Updating the score array is unnecessary since the related counter ensures the new score is returned if no changes have
66+
* occurred.
67+
*
68+
* @param newScore the score to be used to reset the buffer
69+
*/
70+
void tryReset(InnerScore<?> newScore) {
71+
// Skips the reset action when no slot has been written since the last reset and the score is unchanged
72+
if (writtenSinceReset || !Objects.equals(newScore, resetScore)) {
73+
resetScore = newScore;
74+
resetEpoch++;
75+
writtenSinceReset = false;
76+
}
77+
}
78+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance;
2+
3+
import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope;
4+
5+
/**
6+
* Tracks whether the non-dominated score levels (hard or medium) have changed,
7+
* so {@link LateAcceptanceAcceptor} can decide when to reset the {@link LateAcceptanceScoreBuffer}.
8+
* <p>
9+
* {@link DefaultLevelScoreState} is used when the score has more than one level and non-dominated
10+
* level tracking is meaningful.
11+
* {@link NoOpLevelScoreState} is used for single-level scores, where no reset is ever needed.
12+
*
13+
* @see DefaultLevelScoreState
14+
* @see NoOpLevelScoreState
15+
*/
16+
sealed interface LevelScoreState<Solution_> permits DefaultLevelScoreState, NoOpLevelScoreState {
17+
18+
void update(LocalSearchStepScope<Solution_> stepScope);
19+
20+
boolean isNonDominatedLevelChanged(LocalSearchStepScope<Solution_> stepScope);
21+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package ai.timefold.solver.core.impl.localsearch.decider.acceptor.lateacceptance;
2+
3+
import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope;
4+
5+
record NoOpLevelScoreState<Solution_>() implements LevelScoreState<Solution_> {
6+
7+
@Override
8+
public void update(LocalSearchStepScope<Solution_> stepScope) {
9+
// Do nothing
10+
}
11+
12+
@Override
13+
public boolean isNonDominatedLevelChanged(LocalSearchStepScope<Solution_> stepScope) {
14+
return false;
15+
}
16+
}

0 commit comments

Comments
 (0)